2026-09-24 14:35:05 +03:00
|
|
|
|
use chrono::{Duration, NaiveDate, Utc};
|
|
|
|
|
|
use meguri::{
|
|
|
|
|
|
demo,
|
|
|
|
|
|
domain::*,
|
|
|
|
|
|
engine,
|
|
|
|
|
|
generation::{self, Binding, Rule, Variable},
|
|
|
|
|
|
};
|
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
|
use std::collections::BTreeSet;
|
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
|
|
struct Example {
|
|
|
|
|
|
doc: WorkspaceDocument,
|
|
|
|
|
|
team: Uuid,
|
|
|
|
|
|
people: [Uuid; 2],
|
|
|
|
|
|
variables: Vec<Variable>,
|
|
|
|
|
|
rules: Vec<Rule>,
|
|
|
|
|
|
day: NaiveDate,
|
|
|
|
|
|
}
|
|
|
|
|
|
impl Example {
|
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
|
let people = [Uuid::new_v4(), Uuid::new_v4()];
|
|
|
|
|
|
let day = "2026-10-19".parse().unwrap();
|
|
|
|
|
|
let (variables, rules) = demo::project_matrix(day, people[0], people[1]);
|
|
|
|
|
|
let mut doc = WorkspaceDocument::new("Projects".into());
|
|
|
|
|
|
doc.timezone = "Asia/Nicosia".into();
|
|
|
|
|
|
Self {
|
|
|
|
|
|
doc,
|
|
|
|
|
|
team: Uuid::new_v4(),
|
|
|
|
|
|
people,
|
|
|
|
|
|
variables,
|
|
|
|
|
|
rules,
|
|
|
|
|
|
day,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
fn expand(&self) -> generation::Expansion {
|
|
|
|
|
|
generation::expand(
|
|
|
|
|
|
&self.doc,
|
|
|
|
|
|
self.team,
|
|
|
|
|
|
&self.variables,
|
|
|
|
|
|
&self.rules,
|
|
|
|
|
|
0,
|
|
|
|
|
|
&WorkingWindow {
|
|
|
|
|
|
timezone: self.doc.timezone.clone(),
|
|
|
|
|
|
..WorkingWindow::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
&self.people.into_iter().collect(),
|
|
|
|
|
|
engine::handover(&self.doc, self.day).unwrap(),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn matrix_keeps_project_priority_and_weekly_monday_handover_across_dst() {
|
|
|
|
|
|
let e = Example::new();
|
|
|
|
|
|
let out = e.expand();
|
|
|
|
|
|
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
|
|
|
|
|
assert_eq!(out.document.schedules.len(), 4);
|
|
|
|
|
|
assert_eq!(out.document.routes.len(), 4);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
out.document.routes[..3]
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.all(|r| r.window.as_ref().unwrap().timezone == "Asia/Nicosia")
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(out.document.routes[3].conditions.is_empty());
|
|
|
|
|
|
assert!(out.document.routes[3].window.is_none());
|
|
|
|
|
|
let duty = out
|
|
|
|
|
|
.document
|
|
|
|
|
|
.schedules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|s| s.name == "Time off")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let project = out
|
|
|
|
|
|
.document
|
|
|
|
|
|
.schedules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|s| s.name == "Payments · owners")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let from = engine::handover(&out.document, e.day).unwrap();
|
|
|
|
|
|
let to = engine::handover(&out.document, e.day + Duration::days(14)).unwrap();
|
|
|
|
|
|
let timeline = engine::timeline(&out.document, from, to).unwrap();
|
|
|
|
|
|
let shifts: Vec<_> = timeline
|
|
|
|
|
|
.assignments
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|a| a.schedule_id == duty.id)
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert_eq!(shifts.len(), 2);
|
|
|
|
|
|
assert_eq!(shifts[0].user_id, e.people[0]);
|
|
|
|
|
|
assert_eq!(shifts[1].user_id, e.people[1]);
|
|
|
|
|
|
assert_eq!((shifts[0].end - shifts[0].start).num_hours(), 169);
|
|
|
|
|
|
assert_eq!(shifts[0].end, shifts[1].start);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
timeline
|
|
|
|
|
|
.assignments
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|a| a.schedule_id == project.id)
|
|
|
|
|
|
.all(|a| a.user_id == e.people[0])
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn priority_skips_absence_country_holidays_then_uses_effective_fallback() {
|
|
|
|
|
|
let e = Example::new();
|
|
|
|
|
|
let mut doc = e.expand().document;
|
|
|
|
|
|
let project = doc
|
|
|
|
|
|
.schedules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|s| s.name == "Payments · owners")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.id;
|
|
|
|
|
|
let duty = doc
|
|
|
|
|
|
.schedules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|s| s.name == "Time off")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.id;
|
|
|
|
|
|
let third = doc.people[0].id;
|
|
|
|
|
|
doc.people.extend(e.people.map(|id| Person {
|
|
|
|
|
|
id,
|
|
|
|
|
|
user_id: Some(id),
|
|
|
|
|
|
email: None,
|
|
|
|
|
|
display_name: "Person".into(),
|
|
|
|
|
|
country: Some(if id == e.people[0] { "CY" } else { "AM" }.into()),
|
2026-10-07 13:28:43 +01:00
|
|
|
|
aliases: vec![],
|
2026-09-24 14:35:05 +03:00
|
|
|
|
}));
|
|
|
|
|
|
doc.holidays.push(Holiday {
|
|
|
|
|
|
country: "CY".into(),
|
|
|
|
|
|
date: e.day,
|
|
|
|
|
|
name: "Holiday".into(),
|
|
|
|
|
|
});
|
|
|
|
|
|
let from = engine::handover(&doc, e.day).unwrap();
|
|
|
|
|
|
let to = engine::handover(&doc, e.day + Duration::days(1)).unwrap();
|
|
|
|
|
|
let owner = |doc: &WorkspaceDocument| {
|
|
|
|
|
|
engine::timeline(doc, from, to)
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.assignments
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.find(|a| a.schedule_id == project)
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.user_id
|
|
|
|
|
|
};
|
|
|
|
|
|
assert_eq!(owner(&doc), e.people[1]);
|
|
|
|
|
|
doc.absences.push(Absence {
|
|
|
|
|
|
id: Uuid::new_v4(),
|
|
|
|
|
|
user_id: e.people[1],
|
|
|
|
|
|
start: e.day,
|
|
|
|
|
|
end: e.day,
|
|
|
|
|
|
});
|
|
|
|
|
|
// The third email contact is CY too, so CY's holiday skips it independently.
|
|
|
|
|
|
assert_eq!(owner(&doc), e.people[0]);
|
|
|
|
|
|
doc.holidays.clear();
|
|
|
|
|
|
doc.absences.push(Absence {
|
|
|
|
|
|
id: Uuid::new_v4(),
|
|
|
|
|
|
user_id: e.people[0],
|
|
|
|
|
|
start: e.day,
|
|
|
|
|
|
end: e.day,
|
|
|
|
|
|
});
|
|
|
|
|
|
assert_eq!(owner(&doc), third);
|
|
|
|
|
|
doc.absences.push(Absence {
|
|
|
|
|
|
id: Uuid::new_v4(),
|
|
|
|
|
|
user_id: third,
|
|
|
|
|
|
start: e.day,
|
|
|
|
|
|
end: e.day,
|
|
|
|
|
|
});
|
|
|
|
|
|
let substitute = Uuid::new_v4();
|
|
|
|
|
|
doc.people.push(Person {
|
|
|
|
|
|
id: substitute,
|
|
|
|
|
|
user_id: None,
|
|
|
|
|
|
email: Some("cover@example.com".into()),
|
|
|
|
|
|
display_name: "Cover".into(),
|
|
|
|
|
|
country: Some("CY".into()),
|
2026-10-07 13:28:43 +01:00
|
|
|
|
aliases: vec![],
|
2026-09-24 14:35:05 +03:00
|
|
|
|
});
|
|
|
|
|
|
doc.requests.push(ReplacementRequest {
|
|
|
|
|
|
effective_legs: None,
|
|
|
|
|
|
id: Uuid::new_v4(),
|
|
|
|
|
|
absence_id: None,
|
|
|
|
|
|
requested_by: e.people[0],
|
|
|
|
|
|
reason: "Cover".into(),
|
|
|
|
|
|
applied_at: Some(from),
|
|
|
|
|
|
forced_by: Some(e.people[0]),
|
|
|
|
|
|
created_at: from - Duration::days(1),
|
|
|
|
|
|
legs: vec![ReplacementLeg {
|
|
|
|
|
|
schedule_id: duty,
|
|
|
|
|
|
start: from,
|
|
|
|
|
|
end: to,
|
|
|
|
|
|
from_user: e.people[0],
|
|
|
|
|
|
to_user: substitute,
|
|
|
|
|
|
}],
|
|
|
|
|
|
status: RequestStatus::Applied,
|
|
|
|
|
|
required_approvers: BTreeSet::new(),
|
|
|
|
|
|
approvals: BTreeSet::new(),
|
|
|
|
|
|
force_reason: Some("Cover".into()),
|
|
|
|
|
|
});
|
|
|
|
|
|
assert_eq!(owner(&doc), substitute);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn renames_and_reorders_preserve_ids_and_approved_replacements_block_removal() {
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
let first = e.expand();
|
|
|
|
|
|
assert!(first.issues.is_empty());
|
|
|
|
|
|
e.doc = first.document;
|
|
|
|
|
|
let old_ids: BTreeSet<_> = e.doc.schedules.iter().map(|s| s.id).collect();
|
|
|
|
|
|
e.variables[0].name = "Products".into();
|
|
|
|
|
|
e.variables[0].node.entries.reverse();
|
|
|
|
|
|
let entry = e.variables[0]
|
|
|
|
|
|
.node
|
|
|
|
|
|
.entries
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|n| n.value == "Payments")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
entry.value = json!("Payments v2");
|
|
|
|
|
|
e.variables[1]
|
|
|
|
|
|
.node
|
|
|
|
|
|
.entries
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|n| n.key == "Payments")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.key = "Payments v2".into();
|
|
|
|
|
|
let again = e.expand();
|
|
|
|
|
|
assert!(again.issues.is_empty(), "{:?}", again.issues);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
again
|
|
|
|
|
|
.changes
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|c| c.kind == "route" && c.action == "update")
|
|
|
|
|
|
.count()
|
|
|
|
|
|
>= 2,
|
|
|
|
|
|
"Changed routing precedence must be visible in the preview"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
old_ids,
|
|
|
|
|
|
again.document.schedules.iter().map(|s| s.id).collect()
|
|
|
|
|
|
);
|
|
|
|
|
|
let id = e
|
|
|
|
|
|
.doc
|
|
|
|
|
|
.schedules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|s| s.name == "Payments · owners")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.id;
|
|
|
|
|
|
let from = engine::handover(&e.doc, e.day + Duration::days(1)).unwrap();
|
|
|
|
|
|
e.doc.requests.push(ReplacementRequest {
|
|
|
|
|
|
effective_legs: None,
|
|
|
|
|
|
id: Uuid::new_v4(),
|
|
|
|
|
|
absence_id: None,
|
|
|
|
|
|
requested_by: e.people[0],
|
|
|
|
|
|
reason: "Cover".into(),
|
|
|
|
|
|
applied_at: Some(from),
|
|
|
|
|
|
forced_by: Some(e.people[0]),
|
|
|
|
|
|
created_at: from - Duration::days(1),
|
|
|
|
|
|
legs: vec![ReplacementLeg {
|
|
|
|
|
|
schedule_id: id,
|
|
|
|
|
|
start: from,
|
|
|
|
|
|
end: from + Duration::hours(1),
|
|
|
|
|
|
from_user: e.people[0],
|
|
|
|
|
|
to_user: e.people[1],
|
|
|
|
|
|
}],
|
|
|
|
|
|
status: RequestStatus::Applied,
|
|
|
|
|
|
required_approvers: BTreeSet::new(),
|
|
|
|
|
|
approvals: BTreeSet::new(),
|
|
|
|
|
|
force_reason: Some("Cover".into()),
|
|
|
|
|
|
});
|
|
|
|
|
|
e.variables[0]
|
|
|
|
|
|
.node
|
|
|
|
|
|
.entries
|
|
|
|
|
|
.retain(|n| n.value != "Payments v2");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
e.expand()
|
|
|
|
|
|
.issues
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|i| i.message.contains("approved replacements"))
|
|
|
|
|
|
);
|
|
|
|
|
|
e.doc.requests[0].status = RequestStatus::Pending;
|
|
|
|
|
|
let out = e.expand();
|
|
|
|
|
|
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
|
|
|
|
|
assert_eq!(out.document.requests[0].status, RequestStatus::Cancelled);
|
|
|
|
|
|
let mut manual = e.doc.clone();
|
|
|
|
|
|
manual.schedules[0].name = "Edited directly".into();
|
|
|
|
|
|
assert!(generation::validate_manual_edit(&e.doc, &mut manual).is_err());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn bad_dictionary_values_types_scope_cycles_and_bounds_have_actionable_errors() {
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
e.variables[1].node.entries.retain(|n| n.key != "Search");
|
|
|
|
|
|
let out = e.expand();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
out.issues
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|i| i.path.contains("Search") && i.message.contains("key ‘Search’"))
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
e.variables[1]
|
|
|
|
|
|
.node
|
|
|
|
|
|
.entries
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|n| n.key == "Payments")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.entries
|
|
|
|
|
|
.clear();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
e.expand()
|
|
|
|
|
|
.issues
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|i| i.message.contains("1–100"))
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
e.rules[1].blocks[0].fields.insert(
|
|
|
|
|
|
"participants".into(),
|
|
|
|
|
|
Binding {
|
|
|
|
|
|
kind: "literal".into(),
|
|
|
|
|
|
value: json!("wrong type"),
|
|
|
|
|
|
..Default::default()
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(e.expand().issues.iter().any(|i| i.message.contains("list")));
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
let scoped = e.rules[1].blocks[0].id;
|
|
|
|
|
|
e.rules[0].blocks[1].steps[0].recipient.output = Some(scoped);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
e.expand()
|
|
|
|
|
|
.issues
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|i| i.message.contains("output block"))
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
let own = e.rules[1].blocks[0].id;
|
|
|
|
|
|
e.rules[1].blocks[0]
|
|
|
|
|
|
.fields
|
|
|
|
|
|
.get_mut("fallback")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.output = Some(own);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
e.expand()
|
|
|
|
|
|
.issues
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|i| i.message.contains("cycle"))
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
for _ in 0..5 {
|
|
|
|
|
|
let mut i = e.rules[1].iterations[0].clone();
|
|
|
|
|
|
i.id = Uuid::new_v4();
|
|
|
|
|
|
e.rules[1].iterations.push(i);
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
e.expand()
|
|
|
|
|
|
.issues
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|i| i.message.contains("at most 4"))
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
let duplicate = e.variables[1].node.entries[0].clone();
|
|
|
|
|
|
e.variables[1].node.entries.push(duplicate);
|
|
|
|
|
|
assert!(!generation::validate_variables(&e.variables).is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn thirty_projects_expand_deterministically_and_tenants_do_not_share_ids() {
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
let project = e.variables[0].node.entries[0].clone();
|
|
|
|
|
|
let owners = e.variables[1].node.entries[0].clone();
|
|
|
|
|
|
e.variables[0].node.entries.clear();
|
|
|
|
|
|
e.variables[1].node.entries.clear();
|
|
|
|
|
|
for i in 0..30 {
|
|
|
|
|
|
let name = format!("Project {i}");
|
|
|
|
|
|
let mut p = project.clone();
|
|
|
|
|
|
p.id = Uuid::new_v4();
|
|
|
|
|
|
p.value = json!(name);
|
|
|
|
|
|
e.variables[0].node.entries.push(p);
|
|
|
|
|
|
let mut o = owners.clone();
|
|
|
|
|
|
o.id = Uuid::new_v4();
|
|
|
|
|
|
o.key = name;
|
|
|
|
|
|
for n in &mut o.entries {
|
|
|
|
|
|
n.id = Uuid::new_v4();
|
|
|
|
|
|
}
|
|
|
|
|
|
e.variables[1].node.entries.push(o);
|
|
|
|
|
|
}
|
|
|
|
|
|
let first = e.expand();
|
|
|
|
|
|
assert!(first.issues.is_empty(), "{:?}", first.issues);
|
|
|
|
|
|
assert_eq!(first.document.schedules.len(), 31);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
serde_json::to_value(&first.document).unwrap(),
|
|
|
|
|
|
serde_json::to_value(e.expand().document).unwrap()
|
|
|
|
|
|
);
|
|
|
|
|
|
e.team = Uuid::new_v4();
|
|
|
|
|
|
let other = e.expand();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
other.document.schedules.iter().all(|s| !first
|
|
|
|
|
|
.document
|
|
|
|
|
|
.schedules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|old| old.id == s.id))
|
|
|
|
|
|
);
|
|
|
|
|
|
let (variables, rules) = demo::support_queues(e.day, e.people[0], e.people[1]);
|
|
|
|
|
|
let support = generation::expand(
|
|
|
|
|
|
&e.doc,
|
|
|
|
|
|
e.team,
|
|
|
|
|
|
&variables,
|
|
|
|
|
|
&rules,
|
|
|
|
|
|
0,
|
|
|
|
|
|
&WorkingWindow::default(),
|
|
|
|
|
|
&e.people.into_iter().collect(),
|
|
|
|
|
|
Utc::now(),
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(support.issues.is_empty(), "{:?}", support.issues);
|
|
|
|
|
|
assert_eq!(support.document.escalations[0].steps[1].delay_minutes, 15);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn legacy_two_owner_rules_keep_dynamic_bindings_and_resource_identity() {
|
|
|
|
|
|
let mut example = Example::new();
|
|
|
|
|
|
for variable in &mut example.variables {
|
|
|
|
|
|
if variable.name == "responsible" {
|
|
|
|
|
|
for entry in &mut variable.node.entries {
|
|
|
|
|
|
entry.entries.truncate(2);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let expected = example.expand();
|
|
|
|
|
|
let mut rules = serde_json::to_value(&example.rules).unwrap();
|
|
|
|
|
|
for rule in rules.as_array_mut().unwrap() {
|
|
|
|
|
|
for block in rule["blocks"].as_array_mut().unwrap() {
|
|
|
|
|
|
if block["kind"] == "priority" {
|
|
|
|
|
|
block["kind"] = json!("coverage");
|
|
|
|
|
|
let fields = block["fields"].as_object_mut().unwrap();
|
|
|
|
|
|
let mut first = fields.remove("participants").unwrap();
|
|
|
|
|
|
first["path"] = json!(["1"]);
|
|
|
|
|
|
let mut second = first.clone();
|
|
|
|
|
|
second["path"] = json!(["2"]);
|
|
|
|
|
|
fields.insert("primary".into(), first);
|
|
|
|
|
|
fields.insert("secondary".into(), second);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
example.rules = serde_json::from_value(rules).unwrap();
|
|
|
|
|
|
let actual = example.expand();
|
|
|
|
|
|
assert!(actual.issues.is_empty(), "{:?}", actual.issues);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
serde_json::to_value(&expected.document.schedules).unwrap(),
|
|
|
|
|
|
serde_json::to_value(&actual.document.schedules).unwrap()
|
|
|
|
|
|
);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
serde_json::to_value(&expected.document.routes).unwrap(),
|
|
|
|
|
|
serde_json::to_value(&actual.document.routes).unwrap()
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
example
|
|
|
|
|
|
.rules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.flat_map(|r| &r.blocks)
|
|
|
|
|
|
.all(|b| b.kind != "coverage")
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn legacy_schedule_deserializes_to_priority_without_changing_references() {
|
|
|
|
|
|
let id = Uuid::new_v4();
|
|
|
|
|
|
let primary = Uuid::new_v4();
|
|
|
|
|
|
let secondary = Uuid::new_v4();
|
|
|
|
|
|
let fallback = Uuid::new_v4();
|
|
|
|
|
|
let schedule: Schedule = serde_json::from_value(json!({"id":id,"name":"Legacy","handover":"09:30:00","type":"coverage","primary":primary,"secondary":secondary,"fallback":fallback})).unwrap();
|
|
|
|
|
|
let value = serde_json::to_value(&schedule).unwrap();
|
|
|
|
|
|
assert_eq!(value["type"], "priority");
|
|
|
|
|
|
assert_eq!(value["participants"], json!([primary, secondary]));
|
|
|
|
|
|
assert_eq!(value["fallback"], json!(fallback));
|
|
|
|
|
|
assert_eq!(value["id"], json!(id));
|
|
|
|
|
|
assert_eq!(value["handover"], "09:30:00");
|
|
|
|
|
|
assert!(value.get("primary").is_none());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn legacy_records_normalize_recursively_and_preserve_generated_resources() {
|
|
|
|
|
|
let mut example = Example::new();
|
|
|
|
|
|
let expected = example.expand();
|
|
|
|
|
|
let original = serde_json::to_value(&example.variables).unwrap();
|
|
|
|
|
|
let legacy = serde_json::to_string(&example.variables)
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.replace("\"kind\":\"dictionary\"", "\"kind\":\"record\"");
|
|
|
|
|
|
example.variables = serde_json::from_str(&legacy).unwrap();
|
|
|
|
|
|
assert_eq!(serde_json::to_value(&example.variables).unwrap(), original);
|
|
|
|
|
|
let actual = example.expand();
|
|
|
|
|
|
assert!(actual.issues.is_empty(), "{:?}", actual.issues);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
serde_json::to_value(&actual.document.schedules).unwrap(),
|
|
|
|
|
|
serde_json::to_value(&expected.document.schedules).unwrap()
|
|
|
|
|
|
);
|
|
|
|
|
|
let id = Uuid::new_v4();
|
|
|
|
|
|
let nested: generation::Node = serde_json::from_value(json!({"id":id,"kind":"record","entries":[{"id":Uuid::new_v4(),"kind":"record","key":"settings","entries":[{"id":Uuid::new_v4(),"kind":"text","key":"lead","value":"alice@example.com"}]}]})).unwrap();
|
|
|
|
|
|
assert_eq!(nested.id, id);
|
|
|
|
|
|
assert_eq!(nested.kind, "dictionary");
|
|
|
|
|
|
assert_eq!(nested.entries[0].kind, "dictionary");
|
|
|
|
|
|
assert_eq!(nested.entries[0].entries[0].value, "alice@example.com");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn advanced_demos_expand_nested_portfolios_and_tier_lookups() {
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
(e.variables, e.rules) = demo::regional_platform(e.day, e.people[0], e.people[1]);
|
|
|
|
|
|
let expanded = e.expand();
|
|
|
|
|
|
assert!(expanded.issues.is_empty(), "{:?}", expanded.issues);
|
|
|
|
|
|
assert_eq!(expanded.document.schedules.len(), 4);
|
|
|
|
|
|
assert_eq!(expanded.document.routes.len(), 3);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!expanded
|
|
|
|
|
|
.document
|
|
|
|
|
|
.schedules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|s| s.name.contains("Sandbox"))
|
|
|
|
|
|
);
|
|
|
|
|
|
let trace = &expanded.trace[1];
|
|
|
|
|
|
assert_eq!(trace["stages"][0]["count"], 2);
|
|
|
|
|
|
let singleton = &expanded.trace[0]["rows"][0]["blocks"][0];
|
|
|
|
|
|
assert_eq!(singleton["fields"]["participants"], json!([e.people[0]]));
|
|
|
|
|
|
assert_eq!(singleton["fields"]["name"], "Incident commander");
|
|
|
|
|
|
assert!(singleton["fields"]["participants"].is_array());
|
|
|
|
|
|
assert_eq!(trace["stages"][1]["count"], 4);
|
|
|
|
|
|
let rows = trace["rows"].as_array().unwrap();
|
|
|
|
|
|
assert_eq!(rows.len(), 4);
|
|
|
|
|
|
assert_eq!(rows.iter().filter(|r| r["status"] == "filtered").count(), 1);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
rows.iter()
|
|
|
|
|
|
.filter(|r| r["status"] == "filtered")
|
|
|
|
|
|
.all(|r| r["resources"].as_array().unwrap().is_empty())
|
|
|
|
|
|
);
|
|
|
|
|
|
let produced: BTreeSet<Uuid> = rows
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.flat_map(|r| r["resources"].as_array().unwrap())
|
|
|
|
|
|
.map(|id| serde_json::from_value(id.clone()).unwrap())
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
produced,
|
|
|
|
|
|
expanded
|
|
|
|
|
|
.document
|
|
|
|
|
|
.generation
|
|
|
|
|
|
.managed
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.filter(|m| m.rule == e.rules[1].id)
|
|
|
|
|
|
.map(|m| m.id)
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
rows.iter()
|
|
|
|
|
|
.any(|r| r["inputs"].to_string().contains("Pacific · Sandbox"))
|
|
|
|
|
|
);
|
|
|
|
|
|
let pacific = expanded
|
|
|
|
|
|
.document
|
|
|
|
|
|
.schedules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|s| s.name == "Pacific · Gateway")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(pacific.handover.to_string(), "05:00:00");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
matches!(&pacific.kind, ScheduleKind::Rotation {shift_days:2, participants,..} if participants[0] == e.people[1])
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
expanded
|
|
|
|
|
|
.document
|
|
|
|
|
|
.escalations
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.all(|p| p.steps.len() == 2)
|
|
|
|
|
|
);
|
|
|
|
|
|
(e.variables, e.rules) = demo::service_tiers(e.day, e.people[0], e.people[1]);
|
|
|
|
|
|
let expanded = e.expand();
|
|
|
|
|
|
assert!(expanded.issues.is_empty(), "{:?}", expanded.issues);
|
|
|
|
|
|
assert_eq!(expanded.document.routes.len(), 4);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!expanded
|
|
|
|
|
|
.document
|
|
|
|
|
|
.schedules
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|s| s.name == "Legacy export")
|
|
|
|
|
|
);
|
|
|
|
|
|
let checkout = expanded
|
|
|
|
|
|
.document
|
|
|
|
|
|
.escalations
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|p| p.name == "Checkout")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(checkout.steps[1].delay_minutes, 5);
|
|
|
|
|
|
assert_eq!(checkout.repeat.count, 2);
|
|
|
|
|
|
assert_eq!(checkout.repeat.wait_minutes, 10);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
expanded
|
|
|
|
|
|
.document
|
|
|
|
|
|
.routes
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|r| r.name == "Checkout")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.window
|
|
|
|
|
|
.is_none()
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
expanded
|
|
|
|
|
|
.document
|
|
|
|
|
|
.routes
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|r| r.name == "Analytics")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.window
|
|
|
|
|
|
.is_some()
|
|
|
|
|
|
);
|
|
|
|
|
|
// One tier edit changes every referencing product without replacing resource identity.
|
|
|
|
|
|
let tiers = e
|
|
|
|
|
|
.variables
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|v| v.name == "service_tiers")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let standard = tiers
|
|
|
|
|
|
.node
|
|
|
|
|
|
.entries
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|n| n.key == "standard")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
standard
|
|
|
|
|
|
.entries
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|n| n.key == "delay")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.value = json!(45);
|
|
|
|
|
|
let changed = e.expand();
|
|
|
|
|
|
assert!(changed.issues.is_empty(), "{:?}", changed.issues);
|
|
|
|
|
|
for name in ["Analytics", "Merchant portal"] {
|
|
|
|
|
|
let before = expanded
|
|
|
|
|
|
.document
|
|
|
|
|
|
.escalations
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|p| p.name == name)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let after = changed
|
|
|
|
|
|
.document
|
|
|
|
|
|
.escalations
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|p| p.name == name)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(before.id, after.id);
|
|
|
|
|
|
assert_eq!(after.steps[1].delay_minutes, 45);
|
|
|
|
|
|
}
|
|
|
|
|
|
// A disabled entry need not resolve its missing retired tier until enabled.
|
|
|
|
|
|
let products = e
|
|
|
|
|
|
.variables
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|v| v.name == "products")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
products
|
|
|
|
|
|
.node
|
|
|
|
|
|
.entries
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|n| n.key == "Legacy export")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.entries
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|n| n.key == "enabled")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.value = json!(true);
|
|
|
|
|
|
assert!(!e.expand().issues.is_empty());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn participant_error_has_source_ids_and_marks_cascading_configuration_error() {
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
let variable = e
|
|
|
|
|
|
.variables
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.find(|v| v.name == "on_call")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
variable.node.entries[0].kind = "date".into();
|
|
|
|
|
|
variable.node.entries[0].value = json!("2026-09-24");
|
|
|
|
|
|
let out = e.expand();
|
|
|
|
|
|
let source = out
|
|
|
|
|
|
.issues
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.find(|i| i.message.contains("not an email"))
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
assert_eq!(source.rule_id, Some(e.rules[0].id));
|
|
|
|
|
|
assert_eq!(source.block_id, Some(e.rules[0].blocks[0].id));
|
|
|
|
|
|
assert!(out.issues.iter().any(|i| i.secondary));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn removed_fixed_assignee_is_rejected_in_documents_and_generation() {
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
serde_json::from_value::<meguri::domain::ScheduleKind>(json!({
|
|
|
|
|
|
"type": "backup", "user": uuid::Uuid::new_v4()
|
|
|
|
|
|
}))
|
|
|
|
|
|
.is_err()
|
|
|
|
|
|
);
|
|
|
|
|
|
let mut example = Example::new();
|
|
|
|
|
|
example.rules[0].blocks[0].kind = "backup".into();
|
|
|
|
|
|
assert!(!example.expand().issues.is_empty());
|
|
|
|
|
|
}
|
2026-10-07 15:30:31 +01:00
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn generated_team_step_uses_workspace_team_without_resolving_person() {
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
let block = e
|
|
|
|
|
|
.rules
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.flat_map(|r| &mut r.blocks)
|
|
|
|
|
|
.find(|b| b.kind == "escalation")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let mut step = block.steps[0].clone();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!step.team_all,
|
|
|
|
|
|
"Legacy steps keep their schedule or person recipient"
|
|
|
|
|
|
);
|
|
|
|
|
|
step.team_all = true;
|
|
|
|
|
|
step.delay = serde_json::from_value(json!({"kind":"literal","value":30})).unwrap();
|
|
|
|
|
|
step.recipient =
|
|
|
|
|
|
serde_json::from_value(json!({"kind":"variable","variable":Uuid::new_v4()})).unwrap();
|
|
|
|
|
|
step.condition = "if-not-acked".into();
|
|
|
|
|
|
block.steps.push(step);
|
|
|
|
|
|
let out = e.expand();
|
|
|
|
|
|
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
|
|
|
|
|
let steps: Vec<_> = out
|
|
|
|
|
|
.document
|
|
|
|
|
|
.escalations
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.flat_map(|e| &e.steps)
|
|
|
|
|
|
.filter(|s| matches!(s.recipient, Recipient::TeamAll))
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(!steps.is_empty());
|
|
|
|
|
|
for step in steps {
|
|
|
|
|
|
assert_eq!(step.delay_minutes, 30);
|
|
|
|
|
|
assert!(matches!(step.condition, EscalationCondition::IfNotAcked));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn provenance_preserves_iteration_paths_affixes_and_source_nodes() {
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
(e.variables, e.rules) = demo::regional_platform(e.day, e.people[0], e.people[1]);
|
|
|
|
|
|
let out = e.expand();
|
|
|
|
|
|
assert!(out.issues.is_empty());
|
|
|
|
|
|
let rows = out.trace.iter().flat_map(|t| t["rows"].as_array().unwrap());
|
|
|
|
|
|
let mut saw_path = false;
|
|
|
|
|
|
for row in rows {
|
|
|
|
|
|
for trace in row["bindings"].as_array().unwrap() {
|
|
|
|
|
|
let binding = &trace["binding"];
|
|
|
|
|
|
if binding["kind"] == "item" && !binding["path"].as_array().unwrap().is_empty() {
|
|
|
|
|
|
saw_path = true;
|
|
|
|
|
|
assert!(!trace["parents"].as_array().unwrap().is_empty());
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
trace["stages"].as_array().unwrap().len(),
|
|
|
|
|
|
binding["path"].as_array().unwrap().len() + 1
|
|
|
|
|
|
);
|
|
|
|
|
|
let last = trace["stages"].as_array().unwrap().last().unwrap();
|
|
|
|
|
|
assert!(last["result"]["node_id"].is_string());
|
|
|
|
|
|
if let Some(text) = last["result"]["value"].as_str() {
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
trace["result"]["value"],
|
|
|
|
|
|
format!(
|
|
|
|
|
|
"{}{}{}",
|
|
|
|
|
|
binding["prefix"].as_str().unwrap(),
|
|
|
|
|
|
text,
|
|
|
|
|
|
binding["suffix"].as_str().unwrap()
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
assert!(saw_path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn random_team_step_ignores_binding_and_rejects_conflicting_types() {
|
|
|
|
|
|
let mut e = Example::new();
|
|
|
|
|
|
let block = e
|
|
|
|
|
|
.rules
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.flat_map(|r| &mut r.blocks)
|
|
|
|
|
|
.find(|b| b.kind == "escalation")
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
block.steps[0].team_random = true;
|
|
|
|
|
|
block.steps[0].user = false;
|
|
|
|
|
|
block.steps[0].recipient =
|
|
|
|
|
|
serde_json::from_value(json!({"kind":"variable","variable":Uuid::new_v4()})).unwrap();
|
|
|
|
|
|
let out = e.expand();
|
|
|
|
|
|
assert!(out.issues.is_empty(), "{:?}", out.issues);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
out.document
|
|
|
|
|
|
.escalations
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.any(|p| matches!(p.steps[0].recipient, Recipient::TeamRandom))
|
|
|
|
|
|
);
|
|
|
|
|
|
e.rules
|
|
|
|
|
|
.iter_mut()
|
|
|
|
|
|
.flat_map(|r| &mut r.blocks)
|
|
|
|
|
|
.find(|b| b.kind == "escalation")
|
|
|
|
|
|
.unwrap()
|
|
|
|
|
|
.steps[0]
|
|
|
|
|
|
.team_all = true;
|
|
|
|
|
|
assert!(!e.expand().issues.is_empty());
|
|
|
|
|
|
}
|