use super::*; use oa_core::source_project::{ProjectLimits, SourceBundle, compile_bundle}; use std::collections::BTreeMap; fn source_bundle() -> SourceBundle { let bundle = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([ ("project.arcadia".into(), "// Project rationale\narcadia 3; project anchors \"Port positions\" { include \"participants.arcadia\"; include \"views.arcadia\"; }\n".into()), ("participants.arcadia".into(), r#"// Port ownership and contracts stay in this file. sa_system system "System" {} sa_actor vessel "Vessel" { sa_port telemetry "Telemetry" { contract { Component { kind Flow; orientation Out; }; }; } sa_port health "Health" { contract { Component { kind Flow; orientation Out; }; }; } } sa_port inbound "Receive" { parent @system; contract { Component { kind Flow; orientation In; }; }; } "#.into()), ("views.arcadia".into(), r#"// Placement rationale stays in the view file. diagram architecture "Architecture" { layer Sa; projection Allocation; } diagram review "Review" { layer Sa; projection Allocation; } node actor_view { diagram @architecture; element @vessel; } node review_view { diagram @review; element @vessel; } "#.into()), ]), }; compile_bundle(&bundle, ProjectLimits::default()) .unwrap() .bundle } fn id(model: &Project, alias: &str) -> Id { model .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); } #[tokio::test] async fn explicit_empty_port_anchors_survive_http_move_and_restart_but_export_compactly() { fn replace_once(source: &mut String, before: &str, after: &str) { assert_eq!( source.matches(before).count(), 1, "ambiguous or absent anchor: {before}" ); let replacement = source.replacen(before, after, 1); assert_ne!(*source, replacement, "fixture mutation must change source"); *source = replacement; } async fn assert_exports(server: &TestServer, current: &StoredProject, node: Id) -> String { let response = server .client .get(server.url(&format!("/projects/{}/source-project", current.resource_id))) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()["etag"], current.etag()); assert_eq!( response.json::().await.unwrap(), current.source_project ); let response = server .client .get(server.url(&format!("/projects/{}/source", current.resource_id))) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let canonical = response.text().await.unwrap(); assert_eq!(oa_core::language::parse(&canonical).unwrap(), current.model); assert_eq!(canonical, oa_core::language::format(¤t.model)); let exported = compile_bundle( &SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([("project.arcadia".into(), canonical.clone())]), }, ProjectLimits::default(), ) .unwrap(); assert_eq!(exported.decoded.project, current.model); assert!(exported.index.declarations.contains_key(&node)); assert!( !exported .index .fields .contains_key(&(node, "port_anchors".into())) ); canonical } let mut authored = source_bundle(); replace_once( authored.files.get_mut("views.arcadia").unwrap(), "diagram @architecture; element @vessel;", "diagram @architecture; element @vessel;\n position { x 12.5; y -4.0; };\n port_anchors [] ; // Keep explicit automatic placement.\n", ); let compiled = compile_bundle(&authored, ProjectLimits::default()).unwrap(); assert_eq!( compiled.bundle, authored, "the HTTP fixture must already be stable" ); let diagram = id(&compiled.decoded.project, "architecture"); let node = id(&compiled.decoded.project, "actor_view"); assert!( compiled .index .fields .contains_key(&(node, "port_anchors".into())) ); assert_eq!( compiled.decoded.project.diagrams[&diagram].nodes[&node].position, oa_core::Position { x: 12.5, y: -4.0 } ); assert!( compiled.decoded.project.diagrams[&diagram].nodes[&node] .port_anchors .is_empty() ); // Independent full-model oracle: only this placement coordinate changes. let mut expected_model = compiled.decoded.project.clone(); expected_model .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&node) .unwrap() .position .x = 25.0; let mut expected_source = authored.clone(); replace_once( expected_source.files.get_mut("views.arcadia").unwrap(), "x 12.5;", "x 25.0;", ); let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let response = server .client .post(server.url("/projects/import-source-project")) .json(&authored) .send() .await .unwrap(); let status = response.status(); let value: Value = response.json().await.unwrap(); assert_eq!(status, StatusCode::CREATED, "{value}"); let original = server .get(value["resource_id"].as_str().unwrap().parse().unwrap()) .await; assert_eq!(original.model, compiled.decoded.project); assert_eq!(original.source_project, authored); assert_pair(&original); assert_exports(&server, &original, node).await; let response = transact( &server, &original, json!([{"MoveNode": {"diagram": diagram, "node": node, "position": {"x": 25.0, "y": -4.0}, "with_descendants": false}}]), false, ) .await; let status = response.status(); let value: Value = response.json().await.unwrap(); assert_eq!(status, StatusCode::OK, "{value}"); assert_eq!(value["changed"], true); let current = server.get(original.resource_id).await; assert_eq!(current.revision, original.revision + 1); assert_eq!(current.model, expected_model); // Exact bundle equality covers the explicit empty field, its rationale, // sibling placement, and all untouched files and source trivia. assert_eq!(current.source_project, expected_source); assert_pair(¤t); let canonical = assert_exports(&server, ¤t, node).await; server.stop().await; let server = TestServer::start(directory.path(), None).await; let reopened = server.get(current.resource_id).await; assert_eq!(reopened.model, expected_model); assert_eq!(reopened.source_project, expected_source); assert_eq!( serde_json::to_value(&reopened).unwrap(), serde_json::to_value(¤t).unwrap() ); assert_pair(&reopened); assert_eq!(assert_exports(&server, &reopened, node).await, canonical); server.stop().await; } #[tokio::test] async fn port_anchor_http_commands_and_source_edits_preserve_per_view_state_after_restart() { let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let original = import(&server).await; let model = &original.model; let (diagram, node, port, other, other_node) = ( id(model, "architecture"), id(model, "actor_view"), id(model, "telemetry"), id(model, "review"), id(model, "review_view"), ); assert!( serde_json::to_value(&model.diagrams[&diagram].nodes[&node]) .unwrap() .get("port_anchors") .is_none() ); let commands = json!([ {"SetPortAnchor":{"diagram":diagram,"node":node,"anchor":{"port":port,"side":"Top","offset":0.25}}}, {"SetPortAnchor":{"diagram":other,"node":other_node,"anchor":{"port":port,"side":"Left","offset":0.75}}} ]); let expected = oa_core::execute_batch( model, &serde_json::from_value::>(commands.clone()).unwrap(), ) .unwrap(); 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_eq!( server.get(original.resource_id).await.source_project, original.source_project ); let response = transact(&server, &original, commands.clone(), false).await; assert_eq!(response.status(), StatusCode::OK); let mut current = server.get(original.resource_id).await; assert_eq!(current.revision, original.revision + 1); assert_eq!(current.model, expected); assert_eq!(current.model.elements, original.model.elements); assert_pair(¤t); for path in ["project.arcadia", "participants.arcadia"] { assert_eq!( current.source_project.files[path], original.source_project.files[path] ); } assert!(current.source_project.files["views.arcadia"].contains("// Placement rationale")); let no_op = transact(&server, ¤t, commands.clone(), false).await; assert_eq!(no_op.headers()["etag"], current.etag()); assert_eq!(no_op.json::().await.unwrap()["changed"], false); assert_eq!( transact(&server, &original, commands, false).await.status(), StatusCode::PRECONDITION_FAILED ); // Source editing changes exactly the same accepted presentation record. let mut edited = current.source_project.clone(); let source = edited.files.get_mut("views.arcadia").unwrap(); assert!(source.contains("offset 0.25;")); *source = source.replace("offset 0.25;", "offset 0.375;"); let written = server .client .put(server.url(&format!("/projects/{}/source-project", current.resource_id))) .header("if-match", current.etag()) .json(&edited) .send() .await .unwrap(); assert_eq!( written.status(), StatusCode::OK, "{}", written.text().await.unwrap() ); current = server.get(current.resource_id).await; assert_eq!( current.model.diagrams[&diagram].nodes[&node].port_anchors[0].offset, 0.375 ); assert_eq!(current.model.diagrams[&other], expected.diagrams[&other]); 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() .text() .await .unwrap(); assert_eq!(oa_core::language::parse(&source).unwrap(), current.model); server.stop().await; let server = TestServer::start(directory.path(), None).await; let reopened = server.get(current.resource_id).await; assert_eq!( serde_json::to_value(&reopened).unwrap(), serde_json::to_value(¤t).unwrap() ); let reset = json!([{"RemovePortAnchor":{"diagram":diagram,"node":node,"port":port}}]); assert_eq!( transact(&server, &reopened, reset.clone(), false) .await .status(), StatusCode::OK ); let reset_state = server.get(current.resource_id).await; assert!( reset_state.model.diagrams[&diagram].nodes[&node] .port_anchors .is_empty() ); assert_eq!( reset_state.model.diagrams[&other], current.model.diagrams[&other] ); assert_pair(&reset_state); let again = transact(&server, &reset_state, reset, false).await; assert_eq!(again.headers()["etag"], reset_state.etag()); assert_eq!(again.json::().await.unwrap()["changed"], false); server.stop().await; } #[tokio::test] async fn port_anchor_http_rejects_invalid_records_and_stale_owners_atomically() { let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let original = import(&server).await; let model = &original.model; let (diagram, node, port, actor, system, inbound) = ( id(model, "architecture"), id(model, "actor_view"), id(model, "telemetry"), id(model, "vessel"), id(model, "system"), id(model, "inbound"), ); let anchor = json!({"port":port,"side":"Bottom","offset":1}); assert_eq!( transact( &server, &original, json!([{"SetPortAnchor":{"diagram":diagram,"node":node,"anchor":anchor}}]), false ) .await .status(), StatusCode::OK ); let current = server.get(original.resource_id).await; for (bad, status) in [ ( json!({"port":port,"side":"Top","offset":-0.01}), StatusCode::UNPROCESSABLE_ENTITY, ), ( json!({"port":port,"side":"Top","offset":1.01}), StatusCode::UNPROCESSABLE_ENTITY, ), ( json!({"port":inbound,"side":"Top","offset":0.5}), StatusCode::UNPROCESSABLE_ENTITY, ), ( json!({"port":actor,"side":"Top","offset":0.5}), StatusCode::UNPROCESSABLE_ENTITY, ), ( json!({"port":Id::new_v4(),"side":"Top","offset":0.5}), StatusCode::UNPROCESSABLE_ENTITY, ), ( json!({"port":port,"side":"Diagonal","offset":0.5}), StatusCode::BAD_REQUEST, ), (json!({"port":port,"side":"Top"}), StatusCode::BAD_REQUEST), ( json!({"port":port,"side":"Top","offset":null}), StatusCode::BAD_REQUEST, ), ( json!({"port":port,"side":"Top","offset":0.5,"unknown":true}), StatusCode::BAD_REQUEST, ), ] { let response = transact( &server, ¤t, json!([ Command::Rename(actor, "Must roll back".into()), {"SetPortAnchor":{"diagram":diagram,"node":node,"anchor":bad}} ]), false, ) .await; assert_eq!( response.status(), status, "{bad}: {}", response.text().await.unwrap() ); } let mut duplicate = current.model.clone(); let anchors = &mut duplicate .diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&node) .unwrap() .port_anchors; anchors.push(anchors[0].clone()); let rejected = server .client .put(server.url(&format!("/projects/{}/model", current.resource_id))) .header("if-match", current.etag()) .json(&duplicate) .send() .await .unwrap(); assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); let reparent = Command::Reparent { element: port, parent: Some(system), }; assert_eq!( transact(&server, ¤t, json!([reparent]), false) .await .status(), StatusCode::UNPROCESSABLE_ENTITY ); assert_eq!( transact( &server, ¤t, json!([{"RemovePortAnchor":{"diagram":diagram,"node":node,"port":inbound}}]), false ) .await .status(), StatusCode::UNPROCESSABLE_ENTITY ); assert_eq!( serde_json::to_value(server.get(current.resource_id).await).unwrap(), serde_json::to_value(¤t).unwrap() ); let remove = json!({"RemovePortAnchor":{"diagram":diagram,"node":node,"port":port}}); assert_eq!( transact(&server, ¤t, json!([remove, reparent]), false) .await .status(), StatusCode::OK ); let moved = server.get(current.resource_id).await; assert_eq!(moved.model.elements[&port].parent, Some(system)); assert!( moved.model.diagrams[&diagram].nodes[&node] .port_anchors .is_empty() ); assert_pair(&moved); assert_eq!( transact(&server, &moved, json!([remove]), false) .await .status(), StatusCode::UNPROCESSABLE_ENTITY ); server.stop().await; } #[tokio::test] async fn port_anchor_served_openapi_matches_serialized_records_and_commands() { let directory = tempfile::tempdir().unwrap(); let server = TestServer::start(directory.path(), None).await; let spec: Value = server .client .get(server.url("/openapi.json")) .send() .await .unwrap() .json() .await .unwrap(); let schemas = &spec["components"]["schemas"]; assert_eq!( schemas["PortAnchorSide"]["enum"], json!(["Left", "Right", "Top", "Bottom"]) ); assert_eq!( schemas["PortAnchor"]["required"], json!(["port", "side", "offset"]) ); assert_eq!(schemas["PortAnchor"]["additionalProperties"], false); assert_eq!(schemas["PortAnchor"]["properties"]["offset"]["minimum"], 0); assert_eq!(schemas["PortAnchor"]["properties"]["offset"]["maximum"], 1); assert_eq!( schemas["NodePlacement"]["properties"]["port_anchors"]["default"], json!([]) ); assert!( !schemas["NodePlacement"]["required"] .as_array() .unwrap() .contains(&json!("port_anchors")) ); let anchor = oa_core::PortAnchor { port: Id::new_v4(), side: oa_core::PortAnchorSide::Right, offset: 0.5, }; assert_eq!( serde_json::to_value(&anchor).unwrap(), json!({"port":anchor.port,"side":"Right","offset":0.5}) ); for (command, name, fields) in [ ( Command::SetPortAnchor { diagram: Id::new_v4(), node: Id::new_v4(), anchor: anchor.clone(), }, "SetPortAnchor", json!(["diagram", "node", "anchor"]), ), ( Command::RemovePortAnchor { diagram: Id::new_v4(), node: Id::new_v4(), port: anchor.port, }, "RemovePortAnchor", json!(["diagram", "node", "port"]), ), ] { let record = serde_json::to_value(&command).unwrap(); assert_eq!( serde_json::from_value::(record.clone()).unwrap(), command ); let schema = schemas["Command"]["oneOf"] .as_array() .unwrap() .iter() .find(|entry| entry["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); for field in fields.as_array().unwrap() { assert!(record[name].get(field.as_str().unwrap()).is_some()); } } server.stop().await; }