Add direct diagram connections, movable ports, and contextual authoring
This commit is contained in:
@@ -8,6 +8,8 @@ use serde_json::{Value, json};
|
||||
use std::{path::Path, sync::Arc};
|
||||
use tokio::{sync::oneshot, task::JoinHandle};
|
||||
|
||||
#[path = "http/port_anchors.rs"]
|
||||
mod port_anchors;
|
||||
#[path = "http/source_projects.rs"]
|
||||
mod source_projects;
|
||||
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
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 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::<Vec<Command>>(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::<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_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::<Value>().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::<Value>().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::<Command>(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;
|
||||
}
|
||||
Reference in New Issue
Block a user