Files

794 lines
25 KiB
Rust
Raw Permalink Normal View History

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()),
}));
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()),
});
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());
}