Preserve editable routes across source projects and REST
This commit is contained in:
@@ -66,7 +66,7 @@ impl ParseLimits {
|
||||
project
|
||||
.diagrams
|
||||
.values()
|
||||
.map(|d| d.nodes.len())
|
||||
.map(|d| d.nodes.len().saturating_add(d.routes.len()))
|
||||
.sum::<usize>(),
|
||||
)
|
||||
.saturating_add(project.artifact_links.len())
|
||||
@@ -348,6 +348,7 @@ fn reference_field(key: &str) -> bool {
|
||||
| "oa"
|
||||
| "sa"
|
||||
| "diagram"
|
||||
| "edge"
|
||||
)
|
||||
}
|
||||
fn enum_field(key: &str) -> bool {
|
||||
@@ -1532,7 +1533,7 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
DeclKind::Diagram => {
|
||||
let defaults =
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview"});
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[]});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
|
||||
@@ -1429,7 +1429,7 @@ fn item_key(
|
||||
"literal:{}",
|
||||
&source[value.span.start..value.span.end]
|
||||
)),
|
||||
EditKind::Record(fields) => ["field", "referenced"].into_iter().find_map(|key| {
|
||||
EditKind::Record(fields) => ["field", "referenced", "edge"].into_iter().find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| format!("{key}:{id}"))
|
||||
|
||||
@@ -811,6 +811,10 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
}
|
||||
}
|
||||
}
|
||||
for diagram in retained["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
assert_eq!(diagram["routes"], serde_json::json!([]));
|
||||
diagram.as_object_mut().unwrap().remove("routes");
|
||||
}
|
||||
assert_eq!(
|
||||
retained, original,
|
||||
"every original V2 field survives; only explicit new compatibility defaults are added"
|
||||
@@ -825,8 +829,8 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
original["relations"].as_object().unwrap().len()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&from_json.diagrams).unwrap(),
|
||||
original["diagrams"]
|
||||
from_json.diagrams.len(),
|
||||
original["diagrams"].as_object().unwrap().len()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&from_json.traces).unwrap(),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
use oa_core::{language, persistence, source_project::*, *};
|
||||
use serde_json::json;
|
||||
|
||||
const SOURCE: &str = r#"arcadia 3;
|
||||
project route_example "Connection review" {
|
||||
oa_activity report "Report" {}
|
||||
oa_activity assess "Assess" {}
|
||||
sa_function selected_assess "Selected assessment" {}
|
||||
interaction signal "Signal" { source @report; target @assess; }
|
||||
trace refinement "Assessment refinement" { oa @assess; sa @selected_assess; }
|
||||
diagram architecture "Architecture" {
|
||||
routes [
|
||||
{ edge @signal; waypoints [{ x 420; y 260; }, { x 690; y 260; }];
|
||||
label { fraction 0.55; offset { x 0; y -16; }; }; },
|
||||
{ edge @refinement; }
|
||||
];
|
||||
}
|
||||
diagram review "Review" { routes [{ edge @signal; }]; }
|
||||
}
|
||||
"#;
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| *candidate == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_source_json_and_bundle_roundtrips_preserve_order_defaults_and_edge_aliases() {
|
||||
let project = language::parse(SOURCE).unwrap();
|
||||
let diagram = &project.diagrams[&id(&project, "architecture")];
|
||||
assert_eq!(diagram.routes.len(), 2);
|
||||
assert_eq!(diagram.routes[0].edge, id(&project, "signal"));
|
||||
assert_eq!(
|
||||
diagram.routes[0].waypoints[1],
|
||||
Position { x: 690., y: 260. }
|
||||
);
|
||||
assert_eq!(diagram.routes[0].label.as_ref().unwrap().fraction, 0.55);
|
||||
assert_eq!(diagram.routes[1].edge, id(&project, "refinement"));
|
||||
assert!(diagram.routes[1].waypoints.is_empty());
|
||||
assert!(diagram.routes[1].label.is_none());
|
||||
assert!(
|
||||
diagram.nodes.is_empty(),
|
||||
"hidden edges can retain authored routes"
|
||||
);
|
||||
assert_eq!(
|
||||
project.diagrams[&id(&project, "review")].routes[0].waypoints,
|
||||
[]
|
||||
);
|
||||
let formatted = language::format(&project);
|
||||
assert!(formatted.contains("edge @signal;"));
|
||||
assert!(formatted.contains("edge @refinement;"));
|
||||
assert!(!formatted.contains(&format!("edge \"{}\"", id(&project, "signal"))));
|
||||
assert_eq!(language::parse(&formatted).unwrap(), project);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
|
||||
project
|
||||
);
|
||||
let compiled = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_schema_three_routes_default_to_empty_and_v2_reader_stays_frozen() {
|
||||
let project =
|
||||
language::parse("arcadia 3; project p \"P\" { diagram view \"View\" {} }").unwrap();
|
||||
assert!(project.diagrams.values().next().unwrap().routes.is_empty());
|
||||
let mut json = serde_json::to_value(&project).unwrap();
|
||||
json["diagrams"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap()
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("routes");
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&json).unwrap()).unwrap(),
|
||||
project
|
||||
);
|
||||
let v2 = include_str!("fixtures/v2-coastal-rescue.arcadia");
|
||||
let migrated = language::parse(v2).unwrap();
|
||||
assert!(
|
||||
migrated
|
||||
.diagrams
|
||||
.values()
|
||||
.all(|diagram| diagram.routes.is_empty())
|
||||
);
|
||||
assert!(
|
||||
language::parse("arcadia 2; project p \"P\" { diagram view \"View\" { routes []; } }")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_route_source_has_located_errors_and_no_silent_nested_defaults() {
|
||||
for route in [
|
||||
"{}",
|
||||
"{ edge @signal; label { offset { x 0; y 0; }; }; }",
|
||||
"{ edge @signal; label { fraction 0.5; }; }",
|
||||
"{ edge @signal; label { fraction 1.1; offset { x 0; y 0; }; }; }",
|
||||
"{ edge @signal; waypoints [{ x 1000001; y 0; }]; }",
|
||||
"{ edge @signal; waypoints [{ x 0; }]; }",
|
||||
"{ edge @signal; label { fraction 0.5; offset { x 0; y 0; }; unknown true; }; }",
|
||||
"{ edge @report; }",
|
||||
"{ edge @absent; }",
|
||||
"{ edge @signal; }, { edge @signal; }",
|
||||
] {
|
||||
let source = format!(
|
||||
"arcadia 3; project p \"P\" {{\n oa_activity report \"Report\" {{}}\n oa_activity assess \"Assess\" {{}}\n interaction signal \"Signal\" {{ source @report; target @assess; }}\n diagram view \"View\" {{\n routes [{route}];\n }}\n}}"
|
||||
);
|
||||
let errors = language::parse(&source).expect_err(route);
|
||||
assert!(
|
||||
errors.iter().all(|error| error.line >= 5),
|
||||
"{route}: {errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_records_share_model_admission_for_source_json_and_bundles() {
|
||||
let project = language::parse(SOURCE).unwrap();
|
||||
// Project + three elements + relation + trace + two diagrams = eight,
|
||||
// plus three route records = eleven. Points do not create identities.
|
||||
let limits = language::ParseLimits {
|
||||
max_records: 10,
|
||||
..language::ParseLimits::UNLIMITED
|
||||
};
|
||||
assert!(limits.check_model(&project).is_err());
|
||||
assert!(
|
||||
language::parse_limited(&language::format(&project), limits)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|error| error.code == "model_limit")
|
||||
);
|
||||
assert!(matches!(
|
||||
persistence::decode_limited(&serde_json::to_vec(&project).unwrap(), limits),
|
||||
Err(persistence::FileError::Limit(_))
|
||||
));
|
||||
let bundle = format_bundle(&project);
|
||||
assert!(
|
||||
compile_bundle(
|
||||
&bundle,
|
||||
ProjectLimits {
|
||||
model: limits,
|
||||
..ProjectLimits::default()
|
||||
}
|
||||
)
|
||||
.unwrap_err()
|
||||
.iter()
|
||||
.any(|error| error.diagnostic.code == "model_limit")
|
||||
);
|
||||
let admitted = language::ParseLimits {
|
||||
max_records: 11,
|
||||
..limits
|
||||
};
|
||||
assert!(admitted.check_model(&project).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_commands_json_and_one_step_undo_recover_exact_model_and_canonical_source() {
|
||||
let original = language::parse(SOURCE).unwrap();
|
||||
let diagram = id(&original, "architecture");
|
||||
let edge = id(&original, "signal");
|
||||
let before = language::format(&original);
|
||||
let mut session = Session::new(original.clone());
|
||||
let set_json = json!({"SetEdgeRoute": {"diagram":diagram,"route":{
|
||||
"edge":edge,"waypoints":[{"x":515.5,"y":-42.0}],
|
||||
"label":{"fraction":0.25,"offset":{"x":12.0,"y":-18.0}}
|
||||
}}});
|
||||
let set: Command = serde_json::from_value(set_json.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&set).unwrap(), set_json);
|
||||
session.execute(set).unwrap();
|
||||
let updated = session.project().clone();
|
||||
assert_eq!(
|
||||
updated.diagrams[&id(&updated, "review")],
|
||||
original.diagrams[&id(&original, "review")]
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&updated)).unwrap(),
|
||||
updated
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &original);
|
||||
assert_eq!(language::format(session.project()), before);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &updated);
|
||||
let remove: Command =
|
||||
serde_json::from_value(json!({"RemoveEdgeRoute":{"diagram":diagram,"edge":edge}})).unwrap();
|
||||
session.execute(remove).unwrap();
|
||||
assert_eq!(session.project().diagrams[&diagram].routes.len(), 1);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &updated);
|
||||
}
|
||||
@@ -27,6 +27,104 @@ fn id(project: &Project, alias: &str) -> Id {
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction first \"First\" { source @a; target @b; }\ninteraction second \"Second\" { source @b; target @a; }\n",
|
||||
),
|
||||
(
|
||||
"views.arcadia",
|
||||
r#"// Diagram routing decisions stay here.
|
||||
diagram review "Review" {
|
||||
routes [
|
||||
// First edge's detour, including unusual numeric spelling.
|
||||
{ edge @ // stable edge
|
||||
first; waypoints [{ x 4.20e2; y // clearance
|
||||
260; }];
|
||||
label { fraction // near target
|
||||
0.7; offset { x 0; y -16; }; }; },
|
||||
// Second edge stays automatic.
|
||||
{ edge @second; }
|
||||
];
|
||||
}
|
||||
"#,
|
||||
),
|
||||
],
|
||||
);
|
||||
let original = compile(&input);
|
||||
let mut changed = original.decoded.project.clone();
|
||||
let diagram = id(&changed, "review");
|
||||
let first = id(&changed, "first");
|
||||
let routes = &mut changed.diagrams.get_mut(&diagram).unwrap().routes;
|
||||
routes[0].waypoints[0].y = 300.;
|
||||
routes[0].label.as_mut().unwrap().fraction = 0.6;
|
||||
routes.swap(0, 1);
|
||||
changed.relations.get_mut(&first).unwrap().alias = "detour".into();
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
assert_eq!(updated.index.declarations[&diagram].path, "views.arcadia");
|
||||
assert_eq!(updated.bundle.files.len(), original.bundle.files.len());
|
||||
let text = &updated.bundle.files["views.arcadia"];
|
||||
assert!(
|
||||
text.contains("edge @ // stable edge\n detour;"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("x 4.20e2; y // clearance\n 300"), "{text}");
|
||||
assert!(text.contains("fraction // near target\n 0.6"), "{text}");
|
||||
assert!(
|
||||
text.find("// Second edge stays automatic.").unwrap()
|
||||
< text.find("// First edge's detour").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.find("// First edge's detour").unwrap()
|
||||
< text.find("edge @ // stable edge\n detour").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
let mut old_comments = comments(&original.bundle.files["views.arcadia"]);
|
||||
let mut new_comments = comments(text);
|
||||
old_comments.sort();
|
||||
new_comments.sort();
|
||||
assert_eq!(new_comments, old_comments);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
let mut input = bundle(
|
||||
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction link \"Link\" { source @a; target @b; }\n",
|
||||
),
|
||||
(
|
||||
"views.arcadia",
|
||||
"// authored view\ndiagram review \"Review\" {\n routes [{ edge @absent; }];\n}\n",
|
||||
),
|
||||
],
|
||||
);
|
||||
let errors = compile_bundle(&input, ProjectLimits::default()).unwrap_err();
|
||||
assert!(
|
||||
errors.iter().any(|error| error.path == "views.arcadia"
|
||||
&& error.diagnostic.line == 3
|
||||
&& error.diagnostic.message.contains("absent")),
|
||||
"{errors:?}"
|
||||
);
|
||||
input.files.get_mut("views.arcadia").unwrap().clone_from(&
|
||||
"// authored view\ndiagram review \"Review\" {\n routes [{ edge @link; label { fraction -0.1; offset { x 0; y 0; }; }; }];\n}\n".into());
|
||||
let errors = compile_bundle(&input, ProjectLimits::default()).unwrap_err();
|
||||
assert!(
|
||||
errors.iter().any(|error| error.path == "views.arcadia"
|
||||
&& error.diagnostic.line == 3
|
||||
&& error.diagnostic.field.as_deref() == Some("routes")),
|
||||
"{errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn typed() -> SourceBundle {
|
||||
bundle(
|
||||
"arcadia 3; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
|
||||
@@ -140,6 +140,280 @@ fn graph() -> Project {
|
||||
model
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edge_route_transactions_source_and_json_share_defaults_etags_and_atomic_rejection() {
|
||||
let model = oa_core::language::parse(
|
||||
r#"arcadia 3; project routing "Routing" {
|
||||
oa_activity a "Report" {}
|
||||
oa_activity b "Assess" {}
|
||||
interaction signal "Signal" { source @a; target @b; }
|
||||
diagram architecture "Architecture" {}
|
||||
diagram review "Review" {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let find = |alias: &str| {
|
||||
model
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| *candidate == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let diagram = find("architecture");
|
||||
let other = find("review");
|
||||
let edge = find("signal");
|
||||
let element = find("a");
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (id, etag) = server.import(&model).await;
|
||||
let url = server.url(&format!("/projects/{id}/transactions"));
|
||||
// The second route intentionally omits both optional fields, exercising
|
||||
// the public JSON defaults rather than serializing a pre-hydrated struct.
|
||||
let commands = json!([
|
||||
{"SetEdgeRoute":{"diagram":diagram,"route":{
|
||||
"edge":edge,"waypoints":[{"x":420.0,"y":260.0},{"x":690.0,"y":260.0}],
|
||||
"label":{"fraction":0.55,"offset":{"x":0.0,"y":-16.0}}
|
||||
}}},
|
||||
{"SetEdgeRoute":{"diagram":other,"route":{"edge":edge}}}
|
||||
]);
|
||||
let typed: Vec<Command> = serde_json::from_value(commands.clone()).unwrap();
|
||||
let expected = oa_core::execute_batch(&model, &typed).unwrap();
|
||||
let preview = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &etag)
|
||||
.json(&json!({"commands":commands,"dry_run":true}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
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
|
||||
);
|
||||
assert_eq!(server.get(id).await.model, model);
|
||||
|
||||
let commit = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &etag)
|
||||
.json(&json!({"commands":commands}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(commit.status(), StatusCode::OK);
|
||||
let new_etag = commit.headers()["etag"].to_str().unwrap().to_owned();
|
||||
let stored = server.get(id).await;
|
||||
assert_eq!(stored.revision, 2);
|
||||
assert_eq!(stored.model, expected);
|
||||
assert!(stored.model.diagrams[&other].routes[0].waypoints.is_empty());
|
||||
assert!(stored.model.diagrams[&other].routes[0].label.is_none());
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{id}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.contains("edge @signal;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), expected);
|
||||
let imported = server
|
||||
.client
|
||||
.post(server.url("/projects/import"))
|
||||
.header("content-type", "text/plain")
|
||||
.body(source.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(imported.status(), StatusCode::CREATED);
|
||||
let imported: Value = imported.json().await.unwrap();
|
||||
assert_eq!(
|
||||
server
|
||||
.get(imported["resource_id"].as_str().unwrap().parse().unwrap())
|
||||
.await
|
||||
.model,
|
||||
expected
|
||||
);
|
||||
|
||||
let reset = json!({"RemoveEdgeRoute":{"diagram":diagram,"edge":edge}});
|
||||
for stale_command in [commands[0].clone(), reset.clone()] {
|
||||
let response = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &etag)
|
||||
.json(&json!({"commands":[stale_command]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
|
||||
}
|
||||
let no_op = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &new_etag)
|
||||
.json(&json!({"commands":commands}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(no_op.status(), StatusCode::OK);
|
||||
assert_eq!(no_op.headers()["etag"], new_etag);
|
||||
assert_eq!(no_op.json::<Value>().await.unwrap()["changed"], false);
|
||||
|
||||
let invalid_routes = [
|
||||
(json!({"edge":element}), StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(
|
||||
json!({"edge":edge,"waypoints":vec![json!({"x":0,"y":0});129]}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"edge":edge,"waypoints":[{"x":1000001,"y":0}]}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"edge":edge,"label":{"fraction":2,"offset":{"x":0,"y":0}}}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"edge":edge,"label":{"fraction":0.5}}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(json!({"edge":edge,"unknown":true}), StatusCode::BAD_REQUEST),
|
||||
];
|
||||
for (route, status) in invalid_routes {
|
||||
let response = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &new_etag)
|
||||
.json(&json!({"commands":[
|
||||
Command::Rename(element, "Must roll back".into()),
|
||||
{"SetEdgeRoute":{"diagram":diagram,"route":route}}
|
||||
]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
status,
|
||||
"{route}: {}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
}
|
||||
let mut duplicated = expected.clone();
|
||||
let route = duplicated.diagrams[&diagram].routes[0].clone();
|
||||
duplicated
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route);
|
||||
let rejected = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/model")))
|
||||
.header("if-match", &new_etag)
|
||||
.json(&duplicated)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(server.get(id).await.model, stored.model);
|
||||
assert_eq!(server.get(id).await.revision, stored.revision);
|
||||
|
||||
// The source write path hydrates the same metadata, and an equal source
|
||||
// application must not create a revision or erase an explicit empty route.
|
||||
let source_no_op = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/source")))
|
||||
.header("if-match", &new_etag)
|
||||
.header("content-type", "text/plain")
|
||||
.body(source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(source_no_op.status(), StatusCode::OK);
|
||||
assert_eq!(source_no_op.headers()["etag"], new_etag);
|
||||
|
||||
let removed = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &new_etag)
|
||||
.json(&json!({"commands":[reset]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(removed.status(), StatusCode::OK);
|
||||
let removed_etag = removed.headers()["etag"].to_str().unwrap().to_owned();
|
||||
let remaining = server.get(id).await;
|
||||
assert!(remaining.model.diagrams[&diagram].routes.is_empty());
|
||||
assert_eq!(remaining.model.diagrams[&other], expected.diagrams[&other]);
|
||||
let removed_again = server
|
||||
.client
|
||||
.post(&url)
|
||||
.header("if-match", &removed_etag)
|
||||
.json(&json!({"commands":[reset]}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(removed_again.status(), StatusCode::OK);
|
||||
assert_eq!(removed_again.headers()["etag"], removed_etag);
|
||||
assert_eq!(
|
||||
removed_again.json::<Value>().await.unwrap()["changed"],
|
||||
false
|
||||
);
|
||||
server.stop().await;
|
||||
let reopened = TestServer::start(directory.path(), None).await;
|
||||
assert_eq!(reopened.get(id).await.model, remaining.model);
|
||||
assert_eq!(reopened.get(id).await.revision, remaining.revision);
|
||||
reopened.stop().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn route_openapi_describes_the_serde_command_shapes_and_nested_defaults() {
|
||||
let spec: Value = serde_json::from_str(include_str!("../../../docs/openapi.json")).unwrap();
|
||||
let schemas = &spec["components"]["schemas"];
|
||||
assert_eq!(
|
||||
schemas["Diagram"]["properties"]["routes"]["default"],
|
||||
json!([])
|
||||
);
|
||||
assert!(
|
||||
!schemas["Diagram"]["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("routes"))
|
||||
);
|
||||
assert_eq!(schemas["EdgeRoute"]["required"], json!(["edge"]));
|
||||
assert_eq!(
|
||||
schemas["EdgeRoute"]["properties"]["waypoints"]["maxItems"],
|
||||
oa_core::MAX_ROUTE_POINTS
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["EdgeRoute"]["properties"]["waypoints"]["default"],
|
||||
json!([])
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["EdgeLabel"]["required"],
|
||||
json!(["fraction", "offset"])
|
||||
);
|
||||
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["minimum"], 0);
|
||||
assert_eq!(schemas["EdgeLabel"]["properties"]["fraction"]["maximum"], 1);
|
||||
let commands = schemas["Command"]["oneOf"].as_array().unwrap();
|
||||
for (name, fields) in [
|
||||
("SetEdgeRoute", json!(["diagram", "route"])),
|
||||
("RemoveEdgeRoute", json!(["diagram", "edge"])),
|
||||
] {
|
||||
let schema = commands
|
||||
.iter()
|
||||
.find(|schema| schema["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);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_source_and_json_are_rejected_without_stalling_the_catalog() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
+53
-1
@@ -622,7 +622,8 @@ Studio profile; no running history state is persisted or synthesized.
|
||||
----------------------------------------------------
|
||||
|
||||
``diagram`` has name, identity, alias, description, ``layer`` (Oa by default),
|
||||
and ``projection`` (Overview by default). Other projections are Decomposition,
|
||||
``projection`` (Overview by default), and ``routes`` (empty list by default).
|
||||
Other projections are Decomposition,
|
||||
Allocation, FunctionalDataflow, Capabilities, and Traceability. Traceability diagrams can show both layers;
|
||||
other diagrams show their chosen layer.
|
||||
|
||||
@@ -653,6 +654,57 @@ Changing a node's diagram preserves the placement identity, alias, coordinates,
|
||||
size, collapse state, and attached artifacts. The destination must accept the
|
||||
element's layer and must not already contain another placement of that element.
|
||||
|
||||
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
|
||||
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
|
||||
(default ``none``). Route records have no separate UUID or alias. An edge occurs
|
||||
at most once in a diagram's route list; different diagrams MAY store independent
|
||||
routes for the same edge. List order and explicit empty entries are retained by
|
||||
source and JSON round trips, although list order has no routing meaning. Edges
|
||||
need not currently be visible in their diagram.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
routes [
|
||||
{ edge @report_exchange;
|
||||
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
|
||||
label { fraction 0.55; offset { x 0; y -16; }; };
|
||||
}
|
||||
];
|
||||
|
||||
Waypoints are pinned absolute diagram positions in source-to-target traversal
|
||||
order; at most 128 are allowed per edge. Computed endpoints and automatic elbows
|
||||
MUST NOT be persisted as authored waypoints. A label record requires both
|
||||
``fraction`` (finite binary32 from 0 through 1, measured along the computed
|
||||
polyline's arc length) and ``offset`` (a relative x/y diagram displacement).
|
||||
All position/displacement components obey the coordinate bounds in section 2.
|
||||
An absent label uses automatic placement. Unknown fields, missing required
|
||||
nested fields, duplicate route edges, non-edge references and invalid numbers
|
||||
are structural errors. A pinned point that obstructs routing remains stored;
|
||||
geometric routing difficulty is reported separately from structural validity.
|
||||
Each EdgeRoute counts as one record against ``ParseLimits.max_records``; pinned
|
||||
points are bounded by the per-route limit and aggregate input byte limits.
|
||||
|
||||
``SetEdgeRoute { diagram, route }`` upserts an entry by edge identity, retaining
|
||||
the order and contents of unrelated entries. ``RemoveEdgeRoute { diagram, edge }``
|
||||
removes it; removing an absent entry is a no-op for an existing diagram and edge.
|
||||
The latter is the Reset routing operation. Either command participates in the
|
||||
same atomic transactions, validation and undo as other model edits. Deleting a
|
||||
relation or trace removes its routes in every diagram; reidentifying it updates
|
||||
route references without changing geometry. Folding, hiding, changing projection
|
||||
or relocating placements MUST NOT erase routing metadata. Group movement
|
||||
translates pins when both actual endpoint owners move by the same delta; label
|
||||
offsets remain relative. Raw ``EditNode`` and source position edits are exact
|
||||
record edits and do not implicitly translate pins.
|
||||
|
||||
This is an additive schema-3 field. Earlier schema-3 JSON/source that omits
|
||||
``routes`` loads as an empty list. Frozen schema-1 and schema-2 readers do not
|
||||
accept this new field; migration creates empty route lists. Source projects keep
|
||||
routes in their owning diagram declaration/file, resolve ``edge @alias`` across
|
||||
files, and retain route-entry comments by referenced edge identity when entries
|
||||
are reordered. The existing REST transaction endpoint accepts the same externally
|
||||
tagged command records with UUID references and conditional ETags; no separate
|
||||
routing endpoint or UI-only routing model exists.
|
||||
|
||||
``artifact`` has name, identity, alias, description, required ``owner`` ref,
|
||||
``relation`` (References, Satisfies, Verifies, Implements, DerivedFrom;
|
||||
default References), ``uri`` (default empty), ``revision`` (optional string),
|
||||
|
||||
@@ -1735,6 +1735,51 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EdgeLabel": {
|
||||
"type": "object",
|
||||
"description": "Authored label placement relative to the computed polyline. Both fields are required.",
|
||||
"properties": {
|
||||
"fraction": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Fraction of source-to-target polyline arc length."
|
||||
},
|
||||
"offset": {
|
||||
"$ref": "#/components/schemas/Position",
|
||||
"description": "Relative diagram-space displacement."
|
||||
}
|
||||
},
|
||||
"required": ["fraction", "offset"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EdgeRoute": {
|
||||
"type": "object",
|
||||
"description": "One diagram's authored route for an existing Relation or Trace, including currently hidden edges. No separate identity. Only pinned points are stored; explicit empty routes are retained.",
|
||||
"properties": {
|
||||
"edge": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Existing Relation or Trace identity, never a node placement."
|
||||
},
|
||||
"waypoints": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/Position" },
|
||||
"maxItems": 128,
|
||||
"default": [],
|
||||
"description": "Absolute diagram positions in traversal order."
|
||||
},
|
||||
"label": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/components/schemas/EdgeLabel" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"default": null
|
||||
}
|
||||
},
|
||||
"required": ["edge"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Size": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3445,6 +3490,12 @@
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/NodePlacement"
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/EdgeRoute" },
|
||||
"default": [],
|
||||
"description": "Unique by edge identity within this diagram. Stored order is retained. Each entry counts against the total model record admission limit. Missing routes in earlier schema-3 models default to empty."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -4406,6 +4457,40 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"SetEdgeRoute": {
|
||||
"type": "object",
|
||||
"description": "Upsert by edge identity; preserves unrelated route entries and order. Uses existing transaction validation and ETag checks.",
|
||||
"properties": {
|
||||
"diagram": { "type": "string", "format": "uuid" },
|
||||
"route": { "$ref": "#/components/schemas/EdgeRoute" }
|
||||
},
|
||||
"required": ["diagram", "route"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["SetEdgeRoute"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"RemoveEdgeRoute": {
|
||||
"type": "object",
|
||||
"description": "Reset to automatic routing. No-op if this existing diagram has no route for the existing edge.",
|
||||
"properties": {
|
||||
"diagram": { "type": "string", "format": "uuid" },
|
||||
"edge": { "type": "string", "format": "uuid" }
|
||||
},
|
||||
"required": ["diagram", "edge"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["RemoveEdgeRoute"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user