use oa_core::{language, persistence, source_project::*, *}; use serde_json::{Value, json}; use std::collections::BTreeMap; const SOURCE: &str = r#"arcadia 7; project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" { oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {} oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {} interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; } diagram view "View" id "70000000-0000-4000-8000-000000000005" { routes [{ edge @signal; waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; source_attachment { side Left; offset 0.25; }; target_attachment { side Bottom; offset 1; }; }]; } diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; } } "#; fn id(project: &Project, alias: &str) -> Id { project .identities() .into_iter() .find(|(_, a)| *a == alias) .unwrap() .0 } fn original() -> Project { language::parse(SOURCE).unwrap() } fn set(diagram: Id, route: EdgeRoute) -> Command { Command::SetEdgeRoute { diagram, route } } #[test] fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() { let project = original(); let view = id(&project, "view"); let route = &project.diagrams[&view].routes[0]; assert_eq!( route.source_attachment, Some(EdgeAttachment { side: PortAnchorSide::Left, offset: 0.25 }) ); assert_eq!( route.target_attachment, Some(EdgeAttachment { side: PortAnchorSide::Bottom, offset: 1.0 }) ); for text in [SOURCE.to_owned(), language::format(&project)] { assert_eq!(language::parse(&text).unwrap(), project); } assert_eq!( persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(), project ); assert_eq!( compile_bundle(&format_bundle(&project), ProjectLimits::default()) .unwrap() .decoded .project, project ); let automatic = &project.diagrams[&id(&project, "other")].routes[0]; assert_eq!(automatic.source_attachment, None); assert_eq!(automatic.target_attachment, None); let encoded = serde_json::to_value(automatic).unwrap(); assert!(encoded.get("source_attachment").is_none()); assert!(encoded.get("target_attachment").is_none()); let mut with_null = encoded.clone(); with_null["source_attachment"] = Value::Null; with_null["target_attachment"] = Value::Null; assert_eq!( serde_json::from_value::(with_null).unwrap(), *automatic ); assert_eq!( serde_json::from_value::(encoded).unwrap(), *automatic ); // Old source without either new field still means automatic presentation. let old = SOURCE .replace(" source_attachment { side Left; offset 0.25; };\n", "") .replace(" target_attachment { side Bottom; offset 1; };\n", ""); let old = language::parse(&old).unwrap(); assert!( old.diagrams .values() .flat_map(|d| &d.routes) .all(|r| r.source_attachment.is_none() && r.target_attachment.is_none()) ); assert!(!language::format(&old).contains("_attachment")); assert_eq!( persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(), old ); } #[test] fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() { let project = original(); let edge = id(&project, "signal"); for bad in [ json!({}), json!({"side":"Top"}), json!({"offset":0.5}), json!({"side":"Diagonal","offset":0.5}), json!({"side":"Top","offset":null}), json!({"side":"Top","offset":0.5,"unknown":true}), json!({"side":"Top","offset":"NaN"}), ] { for field in ["source_attachment", "target_attachment"] { let mut route = json!({"edge":edge}); route[field] = bad.clone(); assert!( serde_json::from_value::(route).is_err(), "{field}: {bad}" ); } } for bad in [ "{}", "{ side Top; }", "{ offset 0.5; }", "{ side Diagonal; offset 0.5; }", "{ side Top; offset null; }", "{ side Top; offset 0.5; unknown true; }", "{ side Top; offset -0.01; }", "{ side Top; offset 1.01; }", "{ side Top; offset NaN; }", "{ side Top; offset 1e999; }", ] { let source = SOURCE.replace("{ side Left; offset 0.25; }", bad); assert!(language::parse(&source).is_err(), "{bad}"); let bundle = SourceBundle { entry: "p.arcadia".into(), files: BTreeMap::from([("p.arcadia".into(), source)]), }; assert!( compile_bundle(&bundle, ProjectLimits::default()).is_err(), "{bad}" ); } for version in [1, 2] { let legacy = format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}"); assert!( language::parse(&legacy).is_err(), "legacy route extension must not be silently admitted" ); } } #[test] fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() { let project = original(); let view = id(&project, "view"); let first = id(&project, "first"); for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] { for end in [EdgeEnd::Source, EdgeEnd::Target] { let mut route = project.diagrams[&view].routes[0].clone(); route.set_attachment( end, Some(EdgeAttachment { side: PortAnchorSide::Top, offset, }), ); let mut session = Session::new(project.clone()); assert!( session .execute(Command::Batch(vec![ Command::Rename(first, "Must roll back".into()), set(view, route) ])) .is_err() ); assert_eq!(session.project(), &project); assert!(!session.can_undo() && !session.can_redo()); } } for offset in [-0.01, 1.01] { let mut value = serde_json::to_value(&project).unwrap(); value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] = json!(offset); assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err()); } } #[test] fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() { let project = original(); let view = id(&project, "view"); let edge = id(&project, "signal"); let mut session = Session::new(project.clone()); let mut route = project.diagrams[&view].routes[0].clone(); route.source_attachment = Some(EdgeAttachment { side: PortAnchorSide::Right, offset: 0.75, }); let command = set(view, route.clone()); assert_eq!( serde_json::from_value::(serde_json::to_value(&command).unwrap()).unwrap(), command ); session.execute(command).unwrap(); let mut expected = project.clone(); expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone(); assert_eq!( session.project(), &expected, "only this view/end changes; semantic fields and other view stay exact" ); session.undo(); assert_eq!(session.project(), &project); assert!(!session.can_undo() && session.can_redo()); session.redo(); assert_eq!(session.project(), &expected); route.source_attachment = None; session.execute(set(view, route.clone())).unwrap(); let mut reset = expected.clone(); reset.diagrams.get_mut(&view).unwrap().routes[0] = route; assert_eq!( session.project(), &reset, "opposite attachment, pins and label survive endpoint reset" ); session.undo(); assert_eq!(session.project(), &expected); session .execute(Command::RemoveEdgeRoute { diagram: view, edge, }) .unwrap(); let mut removed = expected.clone(); removed.diagrams.get_mut(&view).unwrap().routes.clear(); assert_eq!(session.project(), &removed); session.undo(); assert_eq!(session.project(), &expected); } #[test] fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() { let current = compile_bundle(&SourceBundle { entry: "p.arcadia".into(), files: BTreeMap::from([ ("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()), ("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()), ("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()), ]), }, ProjectLimits::default()).unwrap(); let mut project = current.decoded.project.clone(); let view = id(&project, "view"); project.diagrams.get_mut(&view).unwrap().routes[0] .source_attachment .as_mut() .unwrap() .offset = 0.375; let changed = update_bundle(¤t, &project, ProjectLimits::default()).unwrap(); assert_eq!(changed.decoded.project, project); for path in ["p.arcadia", "model.arcadia"] { assert_eq!(changed.bundle.files[path], current.bundle.files[path]); } assert_eq!( changed.bundle.files["views.arcadia"], current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;") ); assert_eq!( compile_bundle(&changed.bundle, ProjectLimits::default()) .unwrap() .bundle, changed.bundle ); assert_eq!( update_bundle(&changed, &project, ProjectLimits::default()) .unwrap() .bundle, changed.bundle ); }