606 lines
20 KiB
Rust
606 lines
20 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(¤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::<SourceBundle>().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::<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;
|
|
}
|