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