Files
meguri/tests/generation.rs
T
Ultradesu 5260c2828b
Checks / test (push) Failing after 2m25s
NEXT
2026-10-07 15:30:31 +01:00

795 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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());
}
#[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());
}