diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 3dc264f8..88a8465d 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -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::(), ) .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); diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index 426a067b..62262909 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -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}")) diff --git a/crates/oa-core/tests/language.rs b/crates/oa-core/tests/language.rs index ff0e3104..dbb6bd81 100644 --- a/crates/oa-core/tests/language.rs +++ b/crates/oa-core/tests/language.rs @@ -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(), diff --git a/crates/oa-core/tests/route_source.rs b/crates/oa-core/tests/route_source.rs new file mode 100644 index 00000000..8861d66f --- /dev/null +++ b/crates/oa-core/tests/route_source.rs @@ -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); +} diff --git a/crates/oa-core/tests/source_project.rs b/crates/oa-core/tests/source_project.rs index bc5a8edb..129a6f22 100644 --- a/crates/oa-core/tests/source_project.rs +++ b/crates/oa-core/tests/source_project.rs @@ -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}", diff --git a/crates/oa-server/tests/http.rs b/crates/oa-server/tests/http.rs index 15af6b5d..2c8ebafd 100644 --- a/crates/oa-server/tests/http.rs +++ b/crates/oa-server/tests/http.rs @@ -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 = 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::(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::().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::().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(); diff --git a/docs/language.rst b/docs/language.rst index 2eb64c0c..5e0bfcd0 100644 --- a/docs/language.rst +++ b/docs/language.rst @@ -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), diff --git a/docs/openapi.json b/docs/openapi.json index b0386eee..09023ae3 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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": {