Files

459 lines
17 KiB
Rust
Raw Permalink Normal View History

2026-10-07 15:30:31 +01:00
use meguri::{demo, domain::*, generation, generation_service::Draft, portable};
use serde_json::json;
use uuid::Uuid;
fn fixture() -> (Uuid, WorkspaceDocument, Draft) {
let workspace = Uuid::new_v4();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let (variables, rules) = demo::project_matrix("2026-09-28".parse().unwrap(), a, b);
let mut doc = WorkspaceDocument::new("Portable".into());
for (id, email) in [(a, "alice@example.com"), (b, "bob@example.com")] {
doc.people.push(Person {
id,
user_id: None,
display_name: email.into(),
email: Some(email.into()),
country: Some("CY".into()),
});
}
(
workspace,
doc,
Draft {
revision: 1,
shared_revision: Some(0),
variables,
rules,
route_position: 0,
review_id: None,
},
)
}
#[test]
fn export_roundtrip_preserves_ids_people_bindings_and_omitted_inputs() {
let (ws, doc, draft) = fixture();
let source = portable::export_v1(&doc, &draft).unwrap();
let text = source.to_string();
for p in &doc.people {
assert!(!text.contains(&p.id.to_string()));
}
assert!(text.contains("alice@example.com"));
let (imported, changes) = portable::import(ws, &doc, &draft, &text).unwrap();
assert_eq!(
serde_json::to_value(&draft).unwrap(),
serde_json::to_value(&imported).unwrap()
);
assert!(
changes
.as_array()
.unwrap()
.iter()
.all(|c| c["action"] == "unchanged")
);
let empty = json!({"format":"meguri/v1","variables":[],"generators":[]});
let (retained, _) = portable::import(ws, &doc, &draft, &empty.to_string()).unwrap();
assert_eq!(
serde_json::to_value(retained).unwrap(),
serde_json::to_value(draft).unwrap()
);
}
#[test]
fn transfer_between_workspaces_remaps_and_repeat_import_is_stable() {
let (_, doc, draft) = fixture();
let source = portable::export_v1(&doc, &draft).unwrap().to_string();
let target = Uuid::new_v4();
let mut target_doc = doc.clone();
for p in &mut target_doc.people {
p.id = Uuid::new_v4();
}
let base = Draft {
variables: vec![],
rules: vec![],
..draft.clone()
};
let (first, _) = portable::import(target, &target_doc, &base, &source).unwrap();
assert_ne!(first.variables[0].id, draft.variables[0].id);
assert_ne!(first.rules[0].blocks[0].id, draft.rules[0].blocks[0].id);
let (second, changes) = portable::import(target, &target_doc, &first, &source).unwrap();
assert_eq!(
serde_json::to_value(&first).unwrap(),
serde_json::to_value(&second).unwrap()
);
assert!(
changes
.as_array()
.unwrap()
.iter()
.all(|c| c["action"] == "unchanged")
);
let result = generation::expand(
&target_doc,
target,
&first.variables,
&first.rules,
0,
&target_doc.working_interval,
&Default::default(),
chrono::Utc::now(),
);
assert!(result.issues.is_empty(), "{:?}", result.issues);
assert!(result.document.schedules.iter().all(|s| {
match &s.kind {
ScheduleKind::Rotation { participants, .. }
| ScheduleKind::Priority { participants, .. } => participants.iter().all(|id| {
result.document.people.iter().any(|p| p.id == *id)
&& !doc.people.iter().any(|p| p.id == *id)
}),
}
}));
}
#[test]
fn malformed_ambiguous_unknown_fields_and_missing_sources_are_rejected() {
let (ws, doc, draft) = fixture();
assert!(portable::import(ws, &doc, &draft, "{").is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
v["integration"] = json!({"token":"never-accepted"});
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
v["format"] = json!("meguri/v999");
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
let duplicate = v["variables"][0].clone();
v["variables"].as_array_mut().unwrap().push(duplicate);
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
v["generators"][0]["blocks"][0]["fields"]["participants"] =
json!({"kind":"variable","variable":"missing"});
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
let mut v = portable::export_v1(&doc, &draft).unwrap();
v["generators"][0]["blocks"][0]["typo"] = json!(true);
assert!(portable::import(ws, &doc, &draft, &v.to_string()).is_err());
}
#[test]
fn contacts_are_email_only_and_missing_export_email_blocks() {
let (ws, mut doc, draft) = fixture();
doc.people[0].email = None;
assert!(portable::export_v1(&doc, &draft).is_err());
let text = json!({"format":"meguri/v1","variables":[{"name":"new contact","node":{"kind":"person","value":"new@example.com"}}],"generators":[]});
let (out, _) = portable::import(ws, &doc, &draft, &text.to_string()).unwrap();
assert_eq!(out.variables.last().unwrap().node.value, "new@example.com");
assert_eq!(doc.people.len(), 2);
}
#[test]
fn manual_reference_is_mapped_by_exact_name_and_is_not_created() {
let (ws, mut doc, draft) = fixture();
let id = Uuid::new_v4();
doc.schedules.push(Schedule {
id,
name: "Fallback".into(),
handover: default_handover(),
kind: ScheduleKind::Rotation {
participants: vec![doc.people[0].id],
anchor: "2026-09-28".parse().unwrap(),
shift_days: 7,
},
});
let config = json!({"format":"meguri/v1","variables":[],"generators":[{"name":"With fallback","blocks":[{"id":"owners","kind":"priority","fields":{"name":{"kind":"literal","value":"Owners"},"participants":{"kind":"literal","value":["alice@example.com"]},"fallback":{"kind":"literal","value":"Fallback"}}}]}]});
let (imported, _) = portable::import(ws, &doc, &draft, &config.to_string()).unwrap();
assert_eq!(
imported.rules.last().unwrap().blocks[0].fields["fallback"].value,
json!(id)
);
doc.schedules.clear();
assert!(portable::import(ws, &doc, &draft, &config.to_string()).is_err());
}
#[test]
fn blank_text_survives_export_import() {
let (ws, doc, mut draft) = fixture();
draft.variables.push(generation::Variable {
id: Uuid::new_v4(),
name: "blank".into(),
node: generation::Node {
id: Uuid::new_v4(),
kind: "text".into(),
key: String::new(),
value: json!(""),
entries: vec![],
},
});
let text = portable::export_v1(&doc, &draft).unwrap().to_string();
let (out, _) = portable::import(ws, &doc, &draft, &text).unwrap();
assert_eq!(out.variables.last().unwrap().node.value, "");
}
#[test]
fn complex_lookup_conditions_and_nested_paths_roundtrip() {
let (ws, doc, mut draft) = fixture();
for create in [demo::regional_platform, demo::service_tiers] {
let (variables, rules) = create(
"2026-09-28".parse().unwrap(),
doc.people[0].id,
doc.people[1].id,
);
draft.variables = variables;
draft.rules = rules;
let source = portable::export_v1(&doc, &draft).unwrap();
let (out, _) = portable::import(ws, &doc, &draft, &source.to_string()).unwrap();
assert_eq!(
serde_json::to_value(out).unwrap(),
serde_json::to_value(&draft).unwrap()
);
}
}
#[test]
fn person_predicate_constants_transfer_by_email() {
let (ws, doc, mut draft) = fixture();
draft.rules[0].when = Some(generation::Predicate {
left: generation::Binding {
kind: "literal".into(),
value: json!(doc.people[0].id),
..Default::default()
},
operation: "equals".into(),
right: generation::Binding {
kind: "literal".into(),
value: json!(doc.people[1].id),
..Default::default()
},
});
let source = portable::export_v1(&doc, &draft).unwrap();
assert_eq!(
source["generators"][0]["when"]["left"]["value"]["$person"],
"alice@example.com"
);
let (out, _) = portable::import(ws, &doc, &draft, &source.to_string()).unwrap();
assert_eq!(
out.rules[0].when.as_ref().unwrap().left.value,
json!(doc.people[0].id)
);
}
#[test]
fn compact_sample_preserves_resources_without_uuids_or_cached_binding_values() {
let (ws, doc, mut base) = fixture();
base.variables.clear();
base.rules.clear();
let source = include_str!("fixtures/portable-v1.json");
let (original, _) = portable::import(ws, &doc, &base, source).unwrap();
let compact = portable::export(&doc, &original).unwrap();
let text = serde_json::to_string_pretty(&compact).unwrap();
assert!(
text.len() * 3 < source.len(),
"{} vs {}",
text.len(),
source.len()
);
assert!(!text.contains("14e49"));
for r in &original.rules {
for b in &r.blocks {
assert!(!text.contains(&b.id.to_string()));
}
}
assert!(!text.contains("New First available"));
assert!(!text.contains("team_all"));
assert!(!text.contains("\"kind\""));
let (imported, _) = portable::import(ws, &doc, &original, &text).unwrap();
let now = chrono::Utc::now();
let expand = |draft: &Draft| {
generation::expand(
&doc,
ws,
&draft.variables,
&draft.rules,
0,
&doc.working_interval,
&Default::default(),
now,
)
};
let before = expand(&original);
let after = expand(&imported);
assert!(before.issues.is_empty(), "{:?}", before.issues);
assert!(after.issues.is_empty(), "{:?}", after.issues);
for (a, b) in [
(
json!(before.document.schedules),
json!(after.document.schedules),
),
(
json!(before.document.escalations),
json!(after.document.escalations),
),
(json!(before.document.routes), json!(after.document.routes)),
] {
assert_eq!(a, b);
}
let (again, _) = portable::import(ws, &doc, &imported, &text).unwrap();
assert_eq!(json!(imported), json!(again));
}
#[test]
fn compact_types_order_escapes_and_output_keys_survive_reordering() {
let (ws, doc, base) = fixture();
let value = json!({"format":"meguri/v2","variables":{"typed":{"$object":[["z",{"$date":"2026-10-01"}],["a",{"$time":"09:00"}],["duration",{"$duration":30}],["text",{"$text":"literal@example.com"}],["$item","plain"]]}},"generators":[]});
let (draft, _) = portable::import(ws, &doc, &base, &value.to_string()).unwrap();
let out = portable::export(&doc, &draft).unwrap();
assert_eq!(out["variables"]["typed"], value["variables"]["typed"]);
let mut compact = portable::export(&doc, &base).unwrap();
let (first, _) = portable::import(ws, &doc, &base, &compact.to_string()).unwrap();
compact["generators"][0]["outputs"]
.as_array_mut()
.unwrap()
.reverse();
let (reordered, _) = portable::import(ws, &doc, &first, &compact.to_string()).unwrap();
let mut a: Vec<_> = first.rules[0].blocks.iter().map(|b| b.id).collect();
let mut b: Vec<_> = reordered.rules[0].blocks.iter().map(|b| b.id).collect();
a.sort();
b.sort();
assert_eq!(a, b);
}
#[test]
fn compact_named_list_reordering_and_field_edit_preserve_entry_ids() {
let (ws, doc, base) = fixture();
let mut v = json!({"format":"meguri/v2","variables":{"projects":[{"name":"a","people":["alice@example.com"]},{"name":"b","people":["bob@example.com"]}]},"generators":[]});
let (first, _) = portable::import(ws, &doc, &base, &v.to_string()).unwrap();
let old = first
.variables
.iter()
.find(|v| v.name == "projects")
.unwrap();
v["variables"]["projects"].as_array_mut().unwrap().reverse();
v["variables"]["projects"][0]["people"] = json!(["carol@example.com"]);
let (second, _) = portable::import(ws, &doc, &first, &v.to_string()).unwrap();
let new = second
.variables
.iter()
.find(|v| v.name == "projects")
.unwrap();
assert_eq!(old.node.entries[0].id, new.node.entries[1].id);
assert_eq!(old.node.entries[1].id, new.node.entries[0].id);
}
#[test]
fn compact_cross_workspace_and_complex_bindings_keep_effective_configuration() {
let (ws, doc, mut draft) = fixture();
for create in [demo::regional_platform, demo::service_tiers] {
(draft.variables, draft.rules) = create(
"2026-09-28".parse().unwrap(),
doc.people[0].id,
doc.people[1].id,
);
let value = portable::export(&doc, &draft).unwrap();
let text = portable::format_config(&value);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&text).unwrap(),
value
);
let (same, _) = portable::import(ws, &doc, &draft, &text).unwrap();
let now = chrono::Utc::now();
let expand = |d: &Draft, workspace| {
generation::expand(
&doc,
workspace,
&d.variables,
&d.rules,
0,
&doc.working_interval,
&Default::default(),
now,
)
};
let before = expand(&draft, ws);
let after = expand(&same, ws);
assert!(after.issues.is_empty(), "{:?}", after.issues);
assert_eq!(
json!(before.document.schedules),
json!(after.document.schedules)
);
assert_eq!(
json!(before.document.escalations),
json!(after.document.escalations)
);
assert_eq!(json!(before.document.routes), json!(after.document.routes));
let target = Uuid::new_v4();
let empty = Draft {
variables: vec![],
rules: vec![],
..draft.clone()
};
let (first, _) = portable::import(target, &doc, &empty, &text).unwrap();
let (again, _) = portable::import(target, &doc, &first, &text).unwrap();
assert_eq!(json!(first), json!(again));
assert!(expand(&first, target).issues.is_empty());
}
}
#[test]
fn guide_example_compiles_to_all_four_output_types() {
let catalog: serde_json::Value =
serde_json::from_str(include_str!("../static/locales/messages.json")).unwrap();
let (ws, doc, mut base) = fixture();
base.variables.clear();
base.rules.clear();
for language in ["en", "ru", "fr"] {
let source = catalog["transfer_guide_example"][language]
.as_str()
.unwrap();
let (draft, _) = portable::import(ws, &doc, &base, source).unwrap();
let expanded = generation::expand(
&doc,
ws,
&draft.variables,
&draft.rules,
0,
&doc.working_interval,
&Default::default(),
chrono::Utc::now(),
);
assert!(
expanded.issues.is_empty(),
"{language}: {:?}",
expanded.issues
);
assert_eq!(expanded.document.schedules.len(), 2);
assert_eq!(expanded.document.escalations.len(), 1);
assert_eq!(expanded.document.routes.len(), 1);
assert!(matches!(
expanded.document.escalations[0].steps[1].recipient,
Recipient::TeamAll
));
assert_eq!(expanded.document.escalations[0].steps[1].delay_minutes, 30);
}
}
#[test]
fn random_team_import_roundtrips_and_rejects_unknown_modes() {
let (ws, doc, mut base) = fixture();
base.variables.clear();
base.rules.clear();
let mut source = json!({"format":"meguri/v2","variables":{},"generators":[{"name":"Random","outputs":[{"key":"policy","type":"escalation","name":"Random","steps":[{"after":30,"team":"random"}]}]}]});
let (draft, _) = portable::import(ws, &doc, &base, &source.to_string()).unwrap();
let out = generation::expand(
&doc,
ws,
&draft.variables,
&draft.rules,
0,
&doc.working_interval,
&Default::default(),
chrono::Utc::now(),
);
assert!(out.issues.is_empty(), "{:?}", out.issues);
assert!(matches!(
out.document.escalations[0].steps[0].recipient,
Recipient::TeamRandom
));
assert_eq!(out.document.escalations[0].steps[0].delay_minutes, 30);
for export in [
portable::export(&doc, &draft).unwrap(),
portable::export_v1(&doc, &draft).unwrap(),
] {
let (roundtrip, _) = portable::import(ws, &doc, &draft, &export.to_string()).unwrap();
assert!(roundtrip.rules[0].blocks[0].steps[0].team_random);
assert!(!roundtrip.rules[0].blocks[0].steps[0].team_all);
}
source["generators"][0]["outputs"][0]["steps"][0]["team"] = json!("sequential");
assert!(portable::import(ws, &doc, &base, &source.to_string()).is_err());
}