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, rules: Vec, 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()), aliases: vec![], })); 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()), aliases: vec![], }); 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 = 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::(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()); }