Preserve editable routes across source projects and REST

This commit is contained in:
2026-09-05 20:19:38 +03:00
parent 4e5a88dc4d
commit a7c6f9678c
8 changed files with 718 additions and 6 deletions
+274
View File
@@ -140,6 +140,280 @@ fn graph() -> Project {
model
}
#[tokio::test]
async fn edge_route_transactions_source_and_json_share_defaults_etags_and_atomic_rejection() {
let model = oa_core::language::parse(
r#"arcadia 3; project routing "Routing" {
oa_activity a "Report" {}
oa_activity b "Assess" {}
interaction signal "Signal" { source @a; target @b; }
diagram architecture "Architecture" {}
diagram review "Review" {}
}"#,
)
.unwrap();
let find = |alias: &str| {
model
.identities()
.into_iter()
.find(|(_, candidate)| *candidate == alias)
.unwrap()
.0
};
let diagram = find("architecture");
let other = find("review");
let edge = find("signal");
let element = find("a");
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (id, etag) = server.import(&model).await;
let url = server.url(&format!("/projects/{id}/transactions"));
// The second route intentionally omits both optional fields, exercising
// the public JSON defaults rather than serializing a pre-hydrated struct.
let commands = json!([
{"SetEdgeRoute":{"diagram":diagram,"route":{
"edge":edge,"waypoints":[{"x":420.0,"y":260.0},{"x":690.0,"y":260.0}],
"label":{"fraction":0.55,"offset":{"x":0.0,"y":-16.0}}
}}},
{"SetEdgeRoute":{"diagram":other,"route":{"edge":edge}}}
]);
let typed: Vec<Command> = serde_json::from_value(commands.clone()).unwrap();
let expected = oa_core::execute_batch(&model, &typed).unwrap();
let preview = server
.client
.post(&url)
.header("if-match", &etag)
.json(&json!({"commands":commands,"dry_run":true}))
.send()
.await
.unwrap();
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
);
assert_eq!(server.get(id).await.model, model);
let commit = server
.client
.post(&url)
.header("if-match", &etag)
.json(&json!({"commands":commands}))
.send()
.await
.unwrap();
assert_eq!(commit.status(), StatusCode::OK);
let new_etag = commit.headers()["etag"].to_str().unwrap().to_owned();
let stored = server.get(id).await;
assert_eq!(stored.revision, 2);
assert_eq!(stored.model, expected);
assert!(stored.model.diagrams[&other].routes[0].waypoints.is_empty());
assert!(stored.model.diagrams[&other].routes[0].label.is_none());
let source = server
.client
.get(server.url(&format!("/projects/{id}/source")))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(source.contains("edge @signal;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), expected);
let imported = server
.client
.post(server.url("/projects/import"))
.header("content-type", "text/plain")
.body(source.clone())
.send()
.await
.unwrap();
assert_eq!(imported.status(), StatusCode::CREATED);
let imported: Value = imported.json().await.unwrap();
assert_eq!(
server
.get(imported["resource_id"].as_str().unwrap().parse().unwrap())
.await
.model,
expected
);
let reset = json!({"RemoveEdgeRoute":{"diagram":diagram,"edge":edge}});
for stale_command in [commands[0].clone(), reset.clone()] {
let response = server
.client
.post(&url)
.header("if-match", &etag)
.json(&json!({"commands":[stale_command]}))
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
}
let no_op = server
.client
.post(&url)
.header("if-match", &new_etag)
.json(&json!({"commands":commands}))
.send()
.await
.unwrap();
assert_eq!(no_op.status(), StatusCode::OK);
assert_eq!(no_op.headers()["etag"], new_etag);
assert_eq!(no_op.json::<Value>().await.unwrap()["changed"], false);
let invalid_routes = [
(json!({"edge":element}), StatusCode::UNPROCESSABLE_ENTITY),
(
json!({"edge":edge,"waypoints":vec![json!({"x":0,"y":0});129]}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"edge":edge,"waypoints":[{"x":1000001,"y":0}]}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"edge":edge,"label":{"fraction":2,"offset":{"x":0,"y":0}}}),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
json!({"edge":edge,"label":{"fraction":0.5}}),
StatusCode::BAD_REQUEST,
),
(json!({"edge":edge,"unknown":true}), StatusCode::BAD_REQUEST),
];
for (route, status) in invalid_routes {
let response = server
.client
.post(&url)
.header("if-match", &new_etag)
.json(&json!({"commands":[
Command::Rename(element, "Must roll back".into()),
{"SetEdgeRoute":{"diagram":diagram,"route":route}}
]}))
.send()
.await
.unwrap();
assert_eq!(
response.status(),
status,
"{route}: {}",
response.text().await.unwrap()
);
}
let mut duplicated = expected.clone();
let route = duplicated.diagrams[&diagram].routes[0].clone();
duplicated
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(route);
let rejected = server
.client
.put(server.url(&format!("/projects/{id}/model")))
.header("if-match", &new_etag)
.json(&duplicated)
.send()
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(server.get(id).await.model, stored.model);
assert_eq!(server.get(id).await.revision, stored.revision);
// The source write path hydrates the same metadata, and an equal source
// application must not create a revision or erase an explicit empty route.
let source_no_op = server
.client
.put(server.url(&format!("/projects/{id}/source")))
.header("if-match", &new_etag)
.header("content-type", "text/plain")
.body(source)
.send()
.await
.unwrap();
assert_eq!(source_no_op.status(), StatusCode::OK);
assert_eq!(source_no_op.headers()["etag"], new_etag);
let removed = server
.client
.post(&url)
.header("if-match", &new_etag)
.json(&json!({"commands":[reset]}))
.send()
.await
.unwrap();
assert_eq!(removed.status(), StatusCode::OK);
let removed_etag = removed.headers()["etag"].to_str().unwrap().to_owned();
let remaining = server.get(id).await;
assert!(remaining.model.diagrams[&diagram].routes.is_empty());
assert_eq!(remaining.model.diagrams[&other], expected.diagrams[&other]);
let removed_again = server
.client
.post(&url)
.header("if-match", &removed_etag)
.json(&json!({"commands":[reset]}))
.send()
.await
.unwrap();
assert_eq!(removed_again.status(), StatusCode::OK);
assert_eq!(removed_again.headers()["etag"], removed_etag);
assert_eq!(
removed_again.json::<Value>().await.unwrap()["changed"],
false
);
server.stop().await;
let reopened = TestServer::start(directory.path(), None).await;
assert_eq!(reopened.get(id).await.model, remaining.model);
assert_eq!(reopened.get(id).await.revision, remaining.revision);
reopened.stop().await;
}
#[test]
fn route_openapi_describes_the_serde_command_shapes_and_nested_defaults() {
let spec: Value = serde_json::from_str(include_str!("../../../docs/openapi.json")).unwrap();
let schemas = &spec["components"]["schemas"];
assert_eq!(
schemas["Diagram"]["properties"]["routes"]["default"],
json!([])
);
assert!(
!schemas["Diagram"]["required"]
.as_array()
.unwrap()
.contains(&json!("routes"))
);
assert_eq!(schemas["EdgeRoute"]["required"], json!(["edge"]));
assert_eq!(
schemas["EdgeRoute"]["properties"]["waypoints"]["maxItems"],
oa_core::MAX_ROUTE_POINTS
);
assert_eq!(
schemas["EdgeRoute"]["properties"]["waypoints"]["default"],
json!([])
);
assert_eq!(
schemas["EdgeLabel"]["required"],
json!(["fraction", "offset"])
);
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["minimum"], 0);
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["maximum"], 1);
let commands = schemas["Command"]["oneOf"].as_array().unwrap();
for (name, fields) in [
("SetEdgeRoute", json!(["diagram", "route"])),
("RemoveEdgeRoute", json!(["diagram", "edge"])),
] {
let schema = commands
.iter()
.find(|schema| schema["properties"].get(name).is_some())
.unwrap();
assert_eq!(schema["required"], json!([name]));
assert_eq!(schema["properties"][name]["required"], fields);
assert_eq!(schema["properties"][name]["additionalProperties"], false);
}
}
#[tokio::test]
async fn oversized_source_and_json_are_rejected_without_stalling_the_catalog() {
let directory = tempfile::tempdir().unwrap();