Files
meguri/tests/generation.rs
T
Aleksandr BogomiakovandClaude 38105c65c2 Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model
NEXT predates the access work, so the identity layer follows master: access comes from
configured groups, instance administrators replace the site administration, and accounts are
created by SSO or by an administrator. Kept from NEXT: manual publication with the Review
changes panel and shared generator drafts, portable import/export (meguri/v2), the layered
generation map with binding provenance, JSM provider changes, contact/approval semantics and
the account-owned Person. Dropped: site administration, local sign-up, and the mandatory
unique email with its contact/account migrations. The manual-publication migration is
renumbered m_0010 after m_0009_access.

Accounts without an email still get a Person in every workspace they reach, a changed sign-in
email updates the account's person, and an account that loses access keeps its linked person so
schedules stay valid. Instance administrators may use Sync and shared drafts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 16:06:10 +01:00

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