345 lines
12 KiB
Rust
345 lines
12 KiB
Rust
use super::*;
|
|
use oa_core::{
|
|
EdgeAttachment, PortAnchorSide,
|
|
source_project::{ProjectLimits, SourceBundle, compile_bundle},
|
|
};
|
|
use std::collections::BTreeMap;
|
|
|
|
fn source_bundle() -> SourceBundle {
|
|
compile_bundle(&SourceBundle {
|
|
entry: "project.arcadia".into(),
|
|
files: BTreeMap::from([
|
|
("project.arcadia".into(), "// project rationale\narcadia 7; project attachments \"Attachments\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
|
|
("model.arcadia".into(), "// semantic ownership\noa_activity first \"First\" {}\noa_activity second \"Second\" {}\ninteraction signal \"Signal\" { source @first; target @second; }\n".into()),
|
|
("views.arcadia".into(), "// view rationale\ndiagram view \"View\" { routes [{ edge @signal; waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; }]; }\ndiagram other \"Other\" { routes [{ edge @signal; }]; }\n".into()),
|
|
]),
|
|
}, ProjectLimits::default()).unwrap().bundle
|
|
}
|
|
|
|
fn id(project: &Project, alias: &str) -> Id {
|
|
project
|
|
.identities()
|
|
.into_iter()
|
|
.find(|(_, a)| *a == alias)
|
|
.unwrap()
|
|
.0
|
|
}
|
|
|
|
async fn import(server: &TestServer) -> StoredProject {
|
|
let response = server
|
|
.client
|
|
.post(server.url("/projects/import-source-project"))
|
|
.json(&source_bundle())
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let value: Value = response.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::CREATED, "{value}");
|
|
server
|
|
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
|
|
.await
|
|
}
|
|
|
|
async fn transact(
|
|
server: &TestServer,
|
|
current: &StoredProject,
|
|
commands: Value,
|
|
dry_run: bool,
|
|
) -> reqwest::Response {
|
|
server
|
|
.client
|
|
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
|
|
.header("if-match", current.etag())
|
|
.json(&json!({"commands":commands,"dry_run":dry_run}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
fn assert_pair(current: &StoredProject) {
|
|
let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap();
|
|
assert_eq!(compiled.decoded.project, current.model);
|
|
assert_eq!(compiled.bundle, current.source_project);
|
|
}
|
|
|
|
async fn assert_unchanged(server: &TestServer, expected: &StoredProject) {
|
|
let actual = server.get(expected.resource_id).await;
|
|
assert_eq!(actual.etag(), expected.etag());
|
|
assert_eq!(
|
|
serde_json::to_value(actual).unwrap(),
|
|
serde_json::to_value(expected).unwrap()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn edge_attachments_http_dry_run_etags_source_parity_and_restart() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let original = import(&server).await;
|
|
let diagram = id(&original.model, "view");
|
|
let mut expected = original.model.clone();
|
|
let route = &mut expected.diagrams.get_mut(&diagram).unwrap().routes[0];
|
|
route.source_attachment = Some(EdgeAttachment {
|
|
side: PortAnchorSide::Left,
|
|
offset: 0.25,
|
|
});
|
|
route.target_attachment = Some(EdgeAttachment {
|
|
side: PortAnchorSide::Bottom,
|
|
offset: 1.0,
|
|
});
|
|
let commands = json!([Command::SetEdgeRoute {
|
|
diagram,
|
|
route: route.clone()
|
|
}]);
|
|
let preview = transact(&server, &original, commands.clone(), true).await;
|
|
assert_eq!(preview.status(), StatusCode::OK);
|
|
let preview: Value = preview.json().await.unwrap();
|
|
assert_eq!(
|
|
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
|
|
expected
|
|
);
|
|
let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap();
|
|
assert_eq!(
|
|
compile_bundle(&proposed, ProjectLimits::default())
|
|
.unwrap()
|
|
.decoded
|
|
.project,
|
|
expected
|
|
);
|
|
assert_unchanged(&server, &original).await;
|
|
|
|
let applied = transact(&server, &original, commands.clone(), false).await;
|
|
assert_eq!(applied.status(), StatusCode::OK);
|
|
let mut current = server.get(original.resource_id).await;
|
|
assert_eq!(applied.headers()["etag"], current.etag());
|
|
assert_eq!(current.revision, original.revision + 1);
|
|
assert_eq!(
|
|
current.model, expected,
|
|
"only the two attachment fields in this view change"
|
|
);
|
|
assert_eq!(current.source_project, proposed);
|
|
assert_pair(¤t);
|
|
for path in ["project.arcadia", "model.arcadia"] {
|
|
assert_eq!(
|
|
current.source_project.files[path],
|
|
original.source_project.files[path]
|
|
);
|
|
}
|
|
assert!(current.source_project.files["views.arcadia"].contains("// view rationale"));
|
|
let noop = transact(&server, ¤t, commands.clone(), false).await;
|
|
assert_eq!(noop.status(), StatusCode::OK);
|
|
assert_eq!(noop.headers()["etag"], current.etag());
|
|
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
|
|
let stale = json!([
|
|
Command::Rename(id(¤t.model, "first"), "Must not persist".into()),
|
|
commands[0].clone()
|
|
]);
|
|
assert_eq!(
|
|
transact(&server, &original, stale, false).await.status(),
|
|
StatusCode::PRECONDITION_FAILED
|
|
);
|
|
assert_unchanged(&server, ¤t).await;
|
|
|
|
let mut edited = current.source_project.clone();
|
|
let text = edited.files.get_mut("views.arcadia").unwrap();
|
|
assert_eq!(text.matches("offset 0.25;").count(), 1);
|
|
*text = text.replace("offset 0.25;", "offset 0.375;");
|
|
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
|
|
.source_attachment
|
|
.as_mut()
|
|
.unwrap()
|
|
.offset = 0.375;
|
|
let response = server
|
|
.client
|
|
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
|
|
.header("if-match", current.etag())
|
|
.json(&edited)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
response.status(),
|
|
StatusCode::OK,
|
|
"{}",
|
|
response.text().await.unwrap()
|
|
);
|
|
current = server.get(current.resource_id).await;
|
|
assert_eq!(current.model, expected);
|
|
assert_eq!(current.source_project, edited);
|
|
assert_pair(¤t);
|
|
let source = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(source.status(), StatusCode::OK);
|
|
assert_eq!(source.headers()["etag"], current.etag());
|
|
assert_eq!(
|
|
oa_core::language::parse(&source.text().await.unwrap()).unwrap(),
|
|
expected
|
|
);
|
|
let bundle = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{}/source-project", current.resource_id)))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(bundle.status(), StatusCode::OK);
|
|
assert_eq!(bundle.headers()["etag"], current.etag());
|
|
assert_eq!(
|
|
bundle.json::<SourceBundle>().await.unwrap(),
|
|
current.source_project
|
|
);
|
|
server.stop().await;
|
|
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
assert_unchanged(&server, ¤t).await;
|
|
let mut route = expected.diagrams[&diagram].routes[0].clone();
|
|
route.source_attachment = None;
|
|
let reset = json!([Command::SetEdgeRoute {
|
|
diagram,
|
|
route: route.clone()
|
|
}]);
|
|
assert_eq!(
|
|
transact(&server, ¤t, reset.clone(), false)
|
|
.await
|
|
.status(),
|
|
StatusCode::OK
|
|
);
|
|
expected.diagrams.get_mut(&diagram).unwrap().routes[0] = route;
|
|
let reset_state = server.get(current.resource_id).await;
|
|
assert_eq!(
|
|
reset_state.model, expected,
|
|
"reset preserves pins, label, opposite end, other view and semantics"
|
|
);
|
|
assert_pair(&reset_state);
|
|
let noop = transact(&server, &reset_state, reset, false).await;
|
|
assert_eq!(noop.status(), StatusCode::OK);
|
|
assert_eq!(noop.headers()["etag"], reset_state.etag());
|
|
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn edge_attachments_http_malformed_and_invalid_offsets_reject_atomically() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let current = import(&server).await;
|
|
let diagram = id(¤t.model, "view");
|
|
let first = id(¤t.model, "first");
|
|
for (bad, status) in [
|
|
(json!({}), StatusCode::BAD_REQUEST),
|
|
(json!({"side":"Top"}), StatusCode::BAD_REQUEST),
|
|
(json!({"offset":0.5}), StatusCode::BAD_REQUEST),
|
|
(
|
|
json!({"side":"Diagonal","offset":0.5}),
|
|
StatusCode::BAD_REQUEST,
|
|
),
|
|
(json!({"side":"Top","offset":null}), StatusCode::BAD_REQUEST),
|
|
(
|
|
json!({"side":"Top","offset":"NaN"}),
|
|
StatusCode::BAD_REQUEST,
|
|
),
|
|
(
|
|
json!({"side":"Top","offset":0.5,"unknown":true}),
|
|
StatusCode::BAD_REQUEST,
|
|
),
|
|
(
|
|
json!({"side":"Top","offset":-0.01}),
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
),
|
|
(
|
|
json!({"side":"Top","offset":1.01}),
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
),
|
|
] {
|
|
for field in ["source_attachment", "target_attachment"] {
|
|
let mut route =
|
|
serde_json::to_value(¤t.model.diagrams[&diagram].routes[0]).unwrap();
|
|
route[field] = bad.clone();
|
|
let commands = json!([Command::Rename(first, "Must roll back".into()), {"SetEdgeRoute":{"diagram":diagram,"route":route}}]);
|
|
let response = transact(&server, ¤t, commands, false).await;
|
|
assert_eq!(
|
|
response.status(),
|
|
status,
|
|
"{field} {bad}: {}",
|
|
response.text().await.unwrap()
|
|
);
|
|
assert_unchanged(&server, ¤t).await;
|
|
}
|
|
}
|
|
let mut invalid = current.model.clone();
|
|
invalid.diagrams.get_mut(&diagram).unwrap().routes[0].target_attachment =
|
|
Some(EdgeAttachment {
|
|
side: PortAnchorSide::Right,
|
|
offset: 2.0,
|
|
});
|
|
let response = server
|
|
.client
|
|
.put(server.url(&format!("/projects/{}/model", current.resource_id)))
|
|
.header("if-match", current.etag())
|
|
.json(&invalid)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
assert_unchanged(&server, ¤t).await;
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn edge_attachments_served_openapi_matches_required_nested_records_and_optional_ends() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let response = server
|
|
.client
|
|
.get(server.url("/openapi.json"))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let schema: Value = response.json().await.unwrap();
|
|
let schemas = &schema["components"]["schemas"];
|
|
let attachment = &schemas["EdgeAttachment"];
|
|
assert_eq!(attachment["required"], json!(["side", "offset"]));
|
|
assert_eq!(attachment["additionalProperties"], false);
|
|
assert_eq!(
|
|
attachment["properties"]["side"]["$ref"],
|
|
"#/components/schemas/PortAnchorSide"
|
|
);
|
|
assert_eq!(attachment["properties"]["offset"]["minimum"], 0);
|
|
assert_eq!(attachment["properties"]["offset"]["maximum"], 1);
|
|
for field in ["source_attachment", "target_attachment"] {
|
|
assert!(
|
|
!schemas["EdgeRoute"]["required"]
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!(field))
|
|
);
|
|
let optional = &schemas["EdgeRoute"]["properties"][field];
|
|
assert_eq!(optional["default"], Value::Null);
|
|
assert_eq!(
|
|
optional["oneOf"],
|
|
json!([{"$ref":"#/components/schemas/EdgeAttachment"},{"type":"null"}])
|
|
);
|
|
}
|
|
let compiled = compile_bundle(&source_bundle(), ProjectLimits::default()).unwrap();
|
|
let diagram = id(&compiled.decoded.project, "view");
|
|
let mut route = compiled.decoded.project.diagrams[&diagram].routes[0].clone();
|
|
let omitted = serde_json::to_value(&route).unwrap();
|
|
assert!(omitted.get("source_attachment").is_none());
|
|
assert!(omitted.get("target_attachment").is_none());
|
|
route.target_attachment = Some(EdgeAttachment {
|
|
side: PortAnchorSide::Right,
|
|
offset: 0.0,
|
|
});
|
|
let encoded = serde_json::to_value(Command::SetEdgeRoute { diagram, route }).unwrap();
|
|
assert_eq!(
|
|
encoded["SetEdgeRoute"]["route"]["target_attachment"],
|
|
json!({"side":"Right","offset":0.0})
|
|
);
|
|
server.stop().await;
|
|
}
|