291 lines
10 KiB
Rust
291 lines
10 KiB
Rust
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::<EdgeRoute>(with_null).unwrap(),
|
|
*automatic
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_value::<EdgeRoute>(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::<EdgeRoute>(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::<Command>(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
|
|
);
|
|
}
|