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