Files
ARCADIA-Studio/crates/oa-server/tests/http/port_anchors.rs
T

448 lines
15 KiB
Rust

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(&current.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(&current);
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, &current, 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(&current);
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(&current).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,
&current,
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, &current, json!([reparent]), false)
.await
.status(),
StatusCode::UNPROCESSABLE_ENTITY
);
assert_eq!(
transact(
&server,
&current,
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(&current).unwrap()
);
let remove = json!({"RemovePortAnchor":{"diagram":diagram,"node":node,"port":port}});
assert_eq!(
transact(&server, &current, 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;
}