use crate::entities::CeremonyDefinition;
use super::{
CeremonyChangeImpact, CeremonyChangeKind, CeremonyDefinitionChange, CeremonyInputDefinition,
CeremonyValidationLocus, ContextWrites, DynamicRoleBinding, InputRequirement, MaxBounces,
MaxTransitions, StateId, TransitionTrigger,
};
fn is_required(input: &CeremonyInputDefinition) -> bool {
matches!(input.requirement(), InputRequirement::Required)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CeremonyDefinitionDiff {
changes: Vec<CeremonyDefinitionChange>,
}
impl CeremonyDefinitionDiff {
#[must_use]
pub fn between(before: &CeremonyDefinition, after: &CeremonyDefinition) -> Self {
let mut changes = Vec::new();
diff_states(before, after, &mut changes);
diff_transitions(before, after, &mut changes);
diff_steps(before, after, &mut changes);
diff_guards(before, after, &mut changes);
diff_roles(before, after, &mut changes);
diff_max_parallel(before, after, &mut changes);
diff_transition_budgets(before, after, &mut changes);
diff_shape(before, after, &mut changes);
Self { changes }
}
#[must_use]
pub fn changes(&self) -> &[CeremonyDefinitionChange] {
&self.changes
}
#[must_use]
pub fn is_identical(&self) -> bool {
self.changes.is_empty()
}
#[must_use]
pub fn strands_running_sessions(&self) -> bool {
self.changes.iter().any(|change| change.impact().strands())
}
#[must_use]
pub fn strand_count(&self) -> usize {
self.changes
.iter()
.filter(|change| change.impact().strands())
.count()
}
}
fn diff_transition_budgets(
before: &CeremonyDefinition,
after: &CeremonyDefinition,
changes: &mut Vec<CeremonyDefinitionChange>,
) {
diff_transition_budget(
before.max_transitions().map(MaxTransitions::get),
after.max_transitions().map(MaxTransitions::get),
changes,
"the total number of transitions a running session may apply",
);
diff_transition_budget(
before.max_bounces().map(MaxBounces::get),
after.max_bounces().map(MaxBounces::get),
changes,
"the number of times a running session may apply one exact edge",
);
}
fn diff_transition_budget(
before: Option<u32>,
after: Option<u32>,
changes: &mut Vec<CeremonyDefinitionChange>,
detail: &'static str,
) {
if before == after {
return;
}
let impact = match (before, after) {
(None, Some(_)) => CeremonyChangeImpact::Strands,
(Some(old), Some(new)) if new < old => CeremonyChangeImpact::Strands,
_ => CeremonyChangeImpact::Carries,
};
record(
changes,
CeremonyChangeKind::Altered,
CeremonyValidationLocus::Definition,
impact,
detail,
);
}
fn record(
changes: &mut Vec<CeremonyDefinitionChange>,
kind: CeremonyChangeKind,
locus: CeremonyValidationLocus,
impact: CeremonyChangeImpact,
detail: &'static str,
) {
changes.push(CeremonyDefinitionChange::new(kind, locus, impact, detail));
}
fn diff_max_parallel(
before: &CeremonyDefinition,
after: &CeremonyDefinition,
changes: &mut Vec<CeremonyDefinitionChange>,
) {
if before.max_parallel() == after.max_parallel() {
return;
}
let impact = if after.max_parallel() < before.max_parallel() {
CeremonyChangeImpact::Strands
} else {
CeremonyChangeImpact::Carries
};
record(
changes,
CeremonyChangeKind::Altered,
CeremonyValidationLocus::Definition,
impact,
"how many steps may be claimed concurrently",
);
}
fn diff_states(
before: &CeremonyDefinition,
after: &CeremonyDefinition,
changes: &mut Vec<CeremonyDefinitionChange>,
) {
for (id, state) in before.states() {
let Some(now) = after.states().get(id) else {
record(
changes,
CeremonyChangeKind::Removed,
CeremonyValidationLocus::state(id.clone()),
CeremonyChangeImpact::Strands,
"a session in this state would have nowhere to be",
);
continue;
};
let locus = CeremonyValidationLocus::state(id.clone());
if now.kind() != state.kind() {
record(
changes,
CeremonyChangeKind::Altered,
locus.clone(),
CeremonyChangeImpact::Strands,
"whether the session may start or finish here",
);
}
if now.execution() != state.execution() {
record(
changes,
CeremonyChangeKind::Altered,
locus.clone(),
CeremonyChangeImpact::Strands,
"whether work in this state runs sequentially or concurrently",
);
}
if now.repeat_policy() != state.repeat_policy() {
record(
changes,
CeremonyChangeKind::Altered,
locus,
CeremonyChangeImpact::Strands,
"when and how often this state repeats",
);
}
}
for id in after.states().keys() {
if !before.states().contains_key(id) {
record(
changes,
CeremonyChangeKind::Added,
CeremonyValidationLocus::state(id.clone()),
CeremonyChangeImpact::Carries,
"a state no running session is in yet",
);
}
}
}
fn transition_key(from: &StateId, trigger: &TransitionTrigger) -> (String, String) {
(from.as_str().to_owned(), trigger.as_str().to_owned())
}
fn diff_transitions(
before: &CeremonyDefinition,
after: &CeremonyDefinition,
changes: &mut Vec<CeremonyDefinitionChange>,
) {
for old in before.transitions() {
let key = transition_key(old.from(), old.trigger());
let now = after
.transitions()
.iter()
.find(|candidate| transition_key(candidate.from(), candidate.trigger()) == key);
let locus = CeremonyValidationLocus::transition(old.from().clone(), old.trigger().clone());
match now {
None => record(
changes,
CeremonyChangeKind::Removed,
locus,
CeremonyChangeImpact::Strands,
"a way out of the state it leaves",
),
Some(now) if now.to() != old.to() => record(
changes,
CeremonyChangeKind::Altered,
locus,
CeremonyChangeImpact::Strands,
"where it leads",
),
Some(now) if now.required_guards() != old.required_guards() => {
let impact = if now.required_guards().is_superset(old.required_guards()) {
CeremonyChangeImpact::Strands
} else {
CeremonyChangeImpact::Carries
};
record(
changes,
CeremonyChangeKind::Altered,
locus,
impact,
"what has to hold before it can fire",
);
}
Some(_) => {}
}
}
for now in after.transitions() {
let key = transition_key(now.from(), now.trigger());
if !before
.transitions()
.iter()
.any(|candidate| transition_key(candidate.from(), candidate.trigger()) == key)
{
record(
changes,
CeremonyChangeKind::Added,
CeremonyValidationLocus::transition(now.from().clone(), now.trigger().clone()),
CeremonyChangeImpact::Carries,
"another way out of a state",
);
}
}
}
fn diff_steps(
before: &CeremonyDefinition,
after: &CeremonyDefinition,
changes: &mut Vec<CeremonyDefinitionChange>,
) {
for (id, old) in before.steps() {
let locus = CeremonyValidationLocus::step(id.clone());
let Some(now) = after.steps().get(id) else {
record(
changes,
CeremonyChangeKind::Removed,
locus,
CeremonyChangeImpact::Strands,
"work a session may not have done yet",
);
continue;
};
if now.state_id() != old.state_id() {
record(
changes,
CeremonyChangeKind::Altered,
locus.clone(),
CeremonyChangeImpact::Strands,
"the state it belongs to",
);
}
if now.handler_kind() != old.handler_kind() {
record(
changes,
CeremonyChangeKind::Altered,
locus.clone(),
CeremonyChangeImpact::Carries,
"who does the work",
);
}
if now.handler_config() != old.handler_config() {
record(
changes,
CeremonyChangeKind::Altered,
locus.clone(),
CeremonyChangeImpact::Carries,
"how the work is asked for",
);
}
if now.dynamic_role_binding() != old.dynamic_role_binding() {
record(
changes,
CeremonyChangeKind::Altered,
locus.clone(),
dynamic_role_binding_impact(old.dynamic_role_binding(), now.dynamic_role_binding()),
"how the role that may claim the step is resolved",
);
}
if now.context_writes() != old.context_writes() {
record(
changes,
CeremonyChangeKind::Altered,
locus.clone(),
context_writes_impact(old.context_writes(), now.context_writes()),
"which successful output fields update ceremony context",
);
}
if now.aggregation() != old.aggregation() {
record(
changes,
CeremonyChangeKind::Altered,
locus.clone(),
CeremonyChangeImpact::Carries,
"how predecessor sibling outputs are aggregated",
);
}
if now.retry_policy() != old.retry_policy()
|| now.timeout() != old.timeout()
|| now.repeat_policy() != old.repeat_policy()
{
record(
changes,
CeremonyChangeKind::Altered,
locus,
CeremonyChangeImpact::Carries,
"how long it may take, how often it may be retried, or when repetition stops",
);
}
}
for id in after.steps().keys() {
if !before.steps().contains_key(id) {
record(
changes,
CeremonyChangeKind::Added,
CeremonyValidationLocus::step(id.clone()),
CeremonyChangeImpact::Strands,
"work a session may already have moved past",
);
}
}
}
fn dynamic_role_binding_impact(
before: Option<&DynamicRoleBinding>,
after: Option<&DynamicRoleBinding>,
) -> CeremonyChangeImpact {
match (before, after) {
(_, None) => CeremonyChangeImpact::Carries,
(Some(before), Some(after))
if before.context_key() == after.context_key()
&& before.allowed_roles().is_subset(after.allowed_roles()) =>
{
CeremonyChangeImpact::Carries
}
(_, Some(_)) => CeremonyChangeImpact::Strands,
}
}
fn context_writes_impact(_before: &ContextWrites, _after: &ContextWrites) -> CeremonyChangeImpact {
CeremonyChangeImpact::Strands
}
fn diff_guards(
before: &CeremonyDefinition,
after: &CeremonyDefinition,
changes: &mut Vec<CeremonyDefinitionChange>,
) {
for (name, old) in before.guards() {
let locus = CeremonyValidationLocus::guard(name.clone());
match after.guards().get(name) {
None => record(
changes,
CeremonyChangeKind::Removed,
locus,
CeremonyChangeImpact::Carries,
"a condition no longer asked for",
),
Some(now) if now.condition() != old.condition() => record(
changes,
CeremonyChangeKind::Altered,
locus,
CeremonyChangeImpact::Strands,
"what satisfies it",
),
Some(_) => {}
}
}
for name in after.guards().keys() {
if !before.guards().contains_key(name) {
record(
changes,
CeremonyChangeKind::Added,
CeremonyValidationLocus::guard(name.clone()),
CeremonyChangeImpact::Carries,
"a condition that only matters where a transition asks for it",
);
}
}
}
fn diff_roles(
before: &CeremonyDefinition,
after: &CeremonyDefinition,
changes: &mut Vec<CeremonyDefinitionChange>,
) {
for (id, old) in before.roles() {
let locus = CeremonyValidationLocus::role(id.clone());
match after.roles().get(id) {
None => record(
changes,
CeremonyChangeKind::Removed,
locus,
CeremonyChangeImpact::Strands,
"whoever was acting as this role can no longer act",
),
Some(now) if now.allowed_actions() != old.allowed_actions() => {
let impact = if old.allowed_actions().is_subset(now.allowed_actions()) {
CeremonyChangeImpact::Carries
} else {
CeremonyChangeImpact::Strands
};
record(
changes,
CeremonyChangeKind::Altered,
locus,
impact,
"what this role is allowed to do",
);
}
Some(_) => {}
}
}
for id in after.roles().keys() {
if !before.roles().contains_key(id) {
record(
changes,
CeremonyChangeKind::Added,
CeremonyValidationLocus::role(id.clone()),
CeremonyChangeImpact::Carries,
"another role at the table",
);
}
}
}
fn diff_shape(
before: &CeremonyDefinition,
after: &CeremonyDefinition,
changes: &mut Vec<CeremonyDefinitionChange>,
) {
for name in before.inputs().keys() {
if !after.inputs().contains_key(name) {
record(
changes,
CeremonyChangeKind::Removed,
CeremonyValidationLocus::input(name.clone()),
CeremonyChangeImpact::Carries,
"an input no longer asked for",
);
}
}
for (name, now) in after.inputs() {
match before.inputs().get(name) {
None => record(
changes,
CeremonyChangeKind::Added,
CeremonyValidationLocus::input(name.clone()),
if is_required(now) {
CeremonyChangeImpact::Strands
} else {
CeremonyChangeImpact::Carries
},
"an input the ceremony now asks for",
),
Some(was) if is_required(was) != is_required(now) => record(
changes,
CeremonyChangeKind::Altered,
CeremonyValidationLocus::input(name.clone()),
if is_required(now) {
CeremonyChangeImpact::Strands
} else {
CeremonyChangeImpact::Carries
},
"whether it has to be supplied",
),
Some(_) => {}
}
}
for name in before.outputs().keys() {
if !after.outputs().contains_key(name) {
record(
changes,
CeremonyChangeKind::Removed,
CeremonyValidationLocus::output(name.clone()),
CeremonyChangeImpact::Carries,
"an output no longer produced",
);
}
}
for name in after.outputs().keys() {
if !before.outputs().contains_key(name) {
record(
changes,
CeremonyChangeKind::Added,
CeremonyValidationLocus::output(name.clone()),
CeremonyChangeImpact::Carries,
"an output not produced before",
);
}
}
if before.description() != after.description() {
record(
changes,
CeremonyChangeKind::Altered,
CeremonyValidationLocus::Definition,
CeremonyChangeImpact::Carries,
"what the ceremony says it is for",
);
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use super::*;
use crate::value_objects::{
CeremonyGuard, CeremonyName, CeremonyRole, CeremonyState, CeremonyStep, CeremonyTransition,
CeremonyVersion, ContextKey, GuardCondition, GuardName, MaxBounces, MaxParallel,
MaxTransitions, RepeatUntilCondition, RetryPolicy, RoleAction, RoleId, StateExecution,
StateIteration, StateRepeatPolicy, StateRepeatUntilCondition, StepHandlerConfig,
StepHandlerKind, StepId, StepIteration, StepOutputField, StepRepeatPolicy,
};
fn state(id: &str) -> StateId {
StateId::new(id).unwrap()
}
fn trigger(name: &str) -> TransitionTrigger {
TransitionTrigger::new(name).unwrap()
}
fn guard_name(name: &str) -> GuardName {
GuardName::new(name).unwrap()
}
fn step_id(id: &str) -> StepId {
StepId::new(id).unwrap()
}
fn step(id: &str, in_state: &str, handler: &str) -> CeremonyStep {
CeremonyStep::new(
step_id(id),
state(in_state),
StepHandlerKind::new(handler).unwrap(),
StepHandlerConfig::empty(),
RetryPolicy::default(),
None,
)
}
fn transition(from: &str, to: &str, on: &str, guards: Vec<GuardName>) -> CeremonyTransition {
CeremonyTransition::new(state(from), state(to), trigger(on), guards).unwrap()
}
fn role(id: &str, actions: BTreeSet<RoleAction>) -> CeremonyRole {
CeremonyRole::new(RoleId::new(id).unwrap(), actions).unwrap()
}
fn facilitator_actions() -> BTreeSet<RoleAction> {
BTreeSet::from([
RoleAction::step(step_id("work")),
RoleAction::transition(trigger("finish")),
])
}
struct Draft {
states: Vec<CeremonyState>,
transitions: Vec<CeremonyTransition>,
steps: Vec<CeremonyStep>,
guards: Vec<CeremonyGuard>,
roles: Vec<CeremonyRole>,
}
impl Draft {
fn baseline() -> Self {
Self {
states: vec![
CeremonyState::initial(state("OPEN")),
CeremonyState::terminal(state("DONE")),
],
transitions: vec![transition(
"OPEN",
"DONE",
"finish",
vec![guard_name("work_done")],
)],
steps: vec![step("work", "OPEN", "noop")],
guards: vec![CeremonyGuard::new(
guard_name("work_done"),
GuardCondition::AllStepsCompleted,
)],
roles: vec![role("FACILITATOR", facilitator_actions())],
}
}
fn build(self) -> CeremonyDefinition {
self.build_with_budgets(None, None)
}
fn build_with_budgets(
self,
max_transitions: Option<MaxTransitions>,
max_bounces: Option<MaxBounces>,
) -> CeremonyDefinition {
CeremonyDefinition::new_with_transition_budgets(
CeremonyName::new("diffed_ceremony").unwrap(),
CeremonyVersion::v1(),
None,
Vec::new(),
Vec::new(),
self.states,
self.transitions,
self.steps,
self.guards,
self.roles,
max_transitions,
max_bounces,
)
.unwrap()
}
}
fn baseline() -> CeremonyDefinition {
Draft::baseline().build()
}
fn state_repeat(max_iterations: u32) -> StateRepeatPolicy {
StateRepeatPolicy::new(
StateIteration::new(max_iterations).unwrap(),
StateRepeatUntilCondition::new(
step_id("work"),
StepOutputField::new("ready").unwrap(),
serde_json::json!(true),
),
)
}
fn assert_single_change(
diff: &CeremonyDefinitionDiff,
locus: &CeremonyValidationLocus,
impact: CeremonyChangeImpact,
detail: &'static str,
) {
assert_eq!(diff.changes().len(), 1, "{:?}", diff.changes());
let change = &diff.changes()[0];
assert_eq!(change.kind(), CeremonyChangeKind::Altered);
assert_eq!(change.locus(), locus);
assert_eq!(change.impact(), impact);
assert_eq!(change.detail(), detail);
}
fn dynamic_binding_for(key: &str, roles: &[&str]) -> DynamicRoleBinding {
DynamicRoleBinding::new(
ContextKey::new(key).unwrap(),
roles.iter().map(|role_id| RoleId::new(*role_id).unwrap()),
)
.unwrap()
}
fn dynamic_binding(key: &str) -> DynamicRoleBinding {
dynamic_binding_for(key, &["FACILITATOR"])
}
fn definition_with_dynamic_roles(allowed_roles: &[&str]) -> CeremonyDefinition {
let mut draft = Draft::baseline();
draft.roles.push(role(
"REVIEWER",
BTreeSet::from([RoleAction::step(step_id("work"))]),
));
draft.steps = vec![step("work", "OPEN", "noop")
.with_dynamic_role_binding(dynamic_binding_for("next_role", allowed_roles))];
draft.build()
}
fn context_writes(destination: &str, source: &str) -> ContextWrites {
ContextWrites::new(BTreeMap::from([(
ContextKey::new(destination).unwrap(),
StepOutputField::new(source).unwrap(),
)]))
}
fn assert_step_policy_change(
diff: &CeremonyDefinitionDiff,
impact: CeremonyChangeImpact,
detail: &'static str,
) {
assert_eq!(diff.changes().len(), 1, "{:?}", diff.changes());
let change = &diff.changes()[0];
assert_eq!(change.kind(), CeremonyChangeKind::Altered);
assert_eq!(
change.locus(),
&CeremonyValidationLocus::step(step_id("work"))
);
assert_eq!(change.impact(), impact);
assert_eq!(change.detail(), detail);
}
#[test]
fn a_definition_does_not_differ_from_itself() {
let diff = CeremonyDefinitionDiff::between(&baseline(), &baseline());
assert!(diff.is_identical());
assert!(!diff.strands_running_sessions());
}
#[test]
fn changing_state_execution_is_a_stranding_state_change() {
let mut concurrent = Draft::baseline();
concurrent.states[0] =
CeremonyState::initial(state("OPEN")).with_execution(StateExecution::Concurrent);
assert_single_change(
&CeremonyDefinitionDiff::between(&baseline(), &concurrent.build()),
&CeremonyValidationLocus::state(state("OPEN")),
CeremonyChangeImpact::Strands,
"whether work in this state runs sequentially or concurrently",
);
}
#[test]
fn adding_changing_or_removing_state_repeat_is_stranding() {
let mut repeating_twice = Draft::baseline();
repeating_twice.states[0] =
CeremonyState::initial(state("OPEN")).with_repeat_policy(state_repeat(2));
let repeating_twice = repeating_twice.build();
let mut repeating_thrice = Draft::baseline();
repeating_thrice.states[0] =
CeremonyState::initial(state("OPEN")).with_repeat_policy(state_repeat(3));
let repeating_thrice = repeating_thrice.build();
for diff in [
CeremonyDefinitionDiff::between(&baseline(), &repeating_twice),
CeremonyDefinitionDiff::between(&repeating_twice, &repeating_thrice),
CeremonyDefinitionDiff::between(&repeating_thrice, &baseline()),
] {
assert_single_change(
&diff,
&CeremonyValidationLocus::state(state("OPEN")),
CeremonyChangeImpact::Strands,
"when and how often this state repeats",
);
}
}
#[test]
fn lowering_parallel_capacity_strands_and_raising_it_carries() {
let baseline = baseline();
let lower = baseline
.clone()
.with_max_parallel(MaxParallel::new(2).unwrap());
let higher = baseline
.clone()
.with_max_parallel(MaxParallel::new(4).unwrap());
assert_single_change(
&CeremonyDefinitionDiff::between(&baseline, &lower),
&CeremonyValidationLocus::Definition,
CeremonyChangeImpact::Strands,
"how many steps may be claimed concurrently",
);
assert_single_change(
&CeremonyDefinitionDiff::between(&baseline, &higher),
&CeremonyValidationLocus::Definition,
CeremonyChangeImpact::Carries,
"how many steps may be claimed concurrently",
);
}
#[test]
fn adding_or_lowering_a_transition_budget_can_strand_a_running_session() {
let baseline = baseline();
let capped = Draft::baseline().build_with_budgets(
Some(MaxTransitions::new(10).unwrap()),
Some(MaxBounces::new(4).unwrap()),
);
let added = CeremonyDefinitionDiff::between(&baseline, &capped);
assert_eq!(added.strand_count(), 2);
assert!(added.changes().iter().all(|change| {
change.kind() == CeremonyChangeKind::Altered
&& change.locus() == &CeremonyValidationLocus::Definition
&& change.impact() == CeremonyChangeImpact::Strands
}));
let lowered = Draft::baseline().build_with_budgets(
Some(MaxTransitions::new(5).unwrap()),
Some(MaxBounces::new(2).unwrap()),
);
let tightening = CeremonyDefinitionDiff::between(&capped, &lowered);
assert_eq!(tightening.strand_count(), 2);
assert!(tightening.changes().iter().all(|change| {
change.kind() == CeremonyChangeKind::Altered
&& change.locus() == &CeremonyValidationLocus::Definition
&& change.impact() == CeremonyChangeImpact::Strands
}));
let raising = CeremonyDefinitionDiff::between(&lowered, &capped);
assert!(!raising.strands_running_sessions());
assert!(raising.changes().iter().all(|change| {
change.kind() == CeremonyChangeKind::Altered
&& change.locus() == &CeremonyValidationLocus::Definition
&& change.impact() == CeremonyChangeImpact::Carries
}));
let removing = CeremonyDefinitionDiff::between(&capped, &baseline);
assert!(!removing.strands_running_sessions());
assert!(removing.changes().iter().all(|change| {
change.kind() == CeremonyChangeKind::Altered
&& change.locus() == &CeremonyValidationLocus::Definition
&& change.impact() == CeremonyChangeImpact::Carries
}));
}
#[test]
fn removing_a_state_strands_whoever_is_in_it() {
let mut draft = Draft::baseline();
draft.states = vec![
CeremonyState::initial(state("OPEN")),
CeremonyState::terminal(state("ELSEWHERE")),
];
draft.transitions = vec![transition(
"OPEN",
"ELSEWHERE",
"finish",
vec![guard_name("work_done")],
)];
let diff = CeremonyDefinitionDiff::between(&baseline(), &draft.build());
assert!(diff.strands_running_sessions());
let removed = diff
.changes()
.iter()
.find(|change| {
change.kind() == CeremonyChangeKind::Removed
&& change.locus() == &CeremonyValidationLocus::state(state("DONE"))
})
.expect("the state that went away should be reported");
assert!(removed.impact().strands());
}
#[test]
fn tightening_a_transition_can_block_a_session_and_relaxing_one_cannot() {
let mut stricter = Draft::baseline();
stricter.guards.push(CeremonyGuard::new(
guard_name("human_approved"),
GuardCondition::HumanApproval,
));
stricter.transitions = vec![transition(
"OPEN",
"DONE",
"finish",
vec![guard_name("work_done"), guard_name("human_approved")],
)];
assert!(
CeremonyDefinitionDiff::between(&baseline(), &stricter.build())
.strands_running_sessions(),
"a session about to move can be blocked by a guard that was not there"
);
let mut looser = Draft::baseline();
looser.transitions = vec![transition("OPEN", "DONE", "finish", Vec::new())];
looser.guards = Vec::new();
assert!(
!CeremonyDefinitionDiff::between(&baseline(), &looser.build())
.strands_running_sessions(),
"dropping a condition only ever lets a session through"
);
}
#[test]
fn narrowing_a_role_takes_something_away_and_widening_it_does_not() {
let mut narrowed = Draft::baseline();
narrowed.roles = vec![role(
"FACILITATOR",
BTreeSet::from([RoleAction::step(step_id("work"))]),
)];
assert!(
CeremonyDefinitionDiff::between(&baseline(), &narrowed.build())
.strands_running_sessions()
);
let mut widened = Draft::baseline();
let mut actions = facilitator_actions();
actions.insert(RoleAction::request_intervention());
widened.roles = vec![role("FACILITATOR", actions)];
assert!(
!CeremonyDefinitionDiff::between(&baseline(), &widened.build())
.strands_running_sessions()
);
}
#[test]
fn changing_how_the_work_is_done_leaves_a_session_where_it_was() {
let mut draft = Draft::baseline();
draft.steps = vec![step("work", "OPEN", "deliberation")];
let diff = CeremonyDefinitionDiff::between(&baseline(), &draft.build());
assert!(!diff.is_identical());
assert!(
!diff.strands_running_sessions(),
"who does the work is not where the session is"
);
assert_eq!(diff.changes().len(), 1);
assert_eq!(diff.changes()[0].detail(), "who does the work");
}
#[test]
fn changing_repeat_policy_is_a_material_step_change() {
let mut draft = Draft::baseline();
draft.steps = vec![
step("work", "OPEN", "noop").with_repeat_policy(StepRepeatPolicy::new(
RepeatUntilCondition::output_field_equals(
StepOutputField::new("ready").unwrap(),
serde_json::json!(true),
),
StepIteration::new(3).unwrap(),
)),
];
let diff = CeremonyDefinitionDiff::between(&baseline(), &draft.build());
assert_eq!(diff.changes().len(), 1);
assert_eq!(
diff.changes()[0].detail(),
"how long it may take, how often it may be retried, or when repetition stops"
);
}
#[test]
fn adding_changing_and_removing_dynamic_role_binding_are_material_step_changes() {
let mut bound = Draft::baseline();
bound.steps =
vec![step("work", "OPEN", "noop")
.with_dynamic_role_binding(dynamic_binding("next_role"))];
let bound = bound.build();
assert_step_policy_change(
&CeremonyDefinitionDiff::between(&baseline(), &bound),
CeremonyChangeImpact::Strands,
"how the role that may claim the step is resolved",
);
let mut changed = Draft::baseline();
changed.steps = vec![step("work", "OPEN", "noop")
.with_dynamic_role_binding(dynamic_binding("fallback_role"))];
assert_step_policy_change(
&CeremonyDefinitionDiff::between(&bound, &changed.build()),
CeremonyChangeImpact::Strands,
"how the role that may claim the step is resolved",
);
assert_step_policy_change(
&CeremonyDefinitionDiff::between(&bound, &baseline()),
CeremonyChangeImpact::Carries,
"how the role that may claim the step is resolved",
);
}
#[test]
fn widening_a_dynamic_role_allow_list_carries_and_narrowing_it_strands() {
let narrow = definition_with_dynamic_roles(&["FACILITATOR"]);
let wide = definition_with_dynamic_roles(&["FACILITATOR", "REVIEWER"]);
assert_step_policy_change(
&CeremonyDefinitionDiff::between(&narrow, &wide),
CeremonyChangeImpact::Carries,
"how the role that may claim the step is resolved",
);
assert_step_policy_change(
&CeremonyDefinitionDiff::between(&wide, &narrow),
CeremonyChangeImpact::Strands,
"how the role that may claim the step is resolved",
);
}
#[test]
fn adding_changing_and_removing_context_writes_are_material_step_changes() {
let mut writing = Draft::baseline();
writing.steps = vec![step("work", "OPEN", "noop")
.with_context_writes(context_writes("next_role", "reviewer"))];
let writing = writing.build();
assert_step_policy_change(
&CeremonyDefinitionDiff::between(&baseline(), &writing),
CeremonyChangeImpact::Strands,
"which successful output fields update ceremony context",
);
let mut changed = Draft::baseline();
changed.steps =
vec![step("work", "OPEN", "noop")
.with_context_writes(context_writes("next_role", "editor"))];
assert_step_policy_change(
&CeremonyDefinitionDiff::between(&writing, &changed.build()),
CeremonyChangeImpact::Strands,
"which successful output fields update ceremony context",
);
assert_step_policy_change(
&CeremonyDefinitionDiff::between(&writing, &baseline()),
CeremonyChangeImpact::Strands,
"which successful output fields update ceremony context",
);
}
#[test]
fn a_step_added_is_work_a_session_may_already_have_moved_past() {
let mut draft = Draft::baseline();
draft.steps.push(step("review", "OPEN", "noop"));
let mut actions = facilitator_actions();
actions.insert(RoleAction::step(step_id("review")));
draft.roles = vec![role("FACILITATOR", actions)];
let diff = CeremonyDefinitionDiff::between(&baseline(), &draft.build());
assert!(diff.strands_running_sessions());
assert_eq!(diff.strand_count(), 1);
}
}