Add state diagram form and representation contract parity
This commit is contained in:
+203
-13
@@ -337,6 +337,8 @@ fn friendly(name: &str) -> String {
|
||||
"Named" => "Data type".into(),
|
||||
"body" => "Occurrence type".into(),
|
||||
"diagram" => "Diagram".into(),
|
||||
"StateMachine" => "State machine".into(),
|
||||
"machine" => "State machine context".into(),
|
||||
"uri" => "Artifact URI".into(),
|
||||
"definition" => "Data type definition".into(),
|
||||
"steps" | "occurrences" => "Ordered occurrences".into(),
|
||||
@@ -408,6 +410,7 @@ fn reference(path: &str) -> bool {
|
||||
| "sa"
|
||||
| "Named"
|
||||
| "diagram"
|
||||
| "machine"
|
||||
) || matches!(
|
||||
parent,
|
||||
"items"
|
||||
@@ -542,7 +545,12 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
let layer = element
|
||||
.as_ref()
|
||||
.map(Element::layer)
|
||||
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())));
|
||||
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())))
|
||||
.or_else(|| {
|
||||
(draft.kind == RecordKind::Diagram)
|
||||
.then(|| serde_json::from_value::<Layer>(value["layer"].clone()).ok())
|
||||
.flatten()
|
||||
});
|
||||
let field = path.rsplit('/').next().unwrap_or_default();
|
||||
if field == "edge" {
|
||||
return project
|
||||
@@ -567,6 +575,8 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
};
|
||||
|
||||
if field == "diagram" {
|
||||
// Keep destinations selectable before retargeting the element. The
|
||||
// element picker below uses the draft destination's membership rules.
|
||||
return project.diagrams.keys().copied().collect();
|
||||
}
|
||||
if field == "owner" || list == "constrained" {
|
||||
@@ -688,18 +698,16 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| id.parse().ok())
|
||||
.unwrap_or(diagram);
|
||||
return project.diagrams.get(&diagram).is_some_and(|d| {
|
||||
(d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
|
||||
&& !d
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.id != draft.original && n.element == candidate.id)
|
||||
});
|
||||
return project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.is_some_and(|d| placement_allowed(project, d, candidate, draft.original));
|
||||
}
|
||||
if !same_layer(candidate) {
|
||||
return false;
|
||||
}
|
||||
match field {
|
||||
"machine" => candidate.kind() == Kind::StateMachine,
|
||||
"parent" => element.as_ref().is_some_and(|child| {
|
||||
if candidate.id == draft.original
|
||||
|| candidate.id == child.id
|
||||
@@ -1002,6 +1010,18 @@ fn group_label(path: &str) -> Option<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
fn placement_allowed(project: &Project, diagram: &Diagram, element: &Element, current: Id) -> bool {
|
||||
(diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
|
||||
&& diagram
|
||||
.projection
|
||||
.state_machine()
|
||||
.is_none_or(|machine| projection::state_machine_contains(project, machine, element.id))
|
||||
&& !diagram
|
||||
.nodes
|
||||
.values()
|
||||
.any(|node| node.id != current && node.element == element.id)
|
||||
}
|
||||
|
||||
fn unit_body(path: &str, value: &Value) -> bool {
|
||||
value.is_string()
|
||||
&& matches!(
|
||||
@@ -1013,17 +1033,31 @@ fn unit_body(path: &str, value: &Value) -> bool {
|
||||
/// Closed choices for the concrete model enums. No runtime schema/plugin layer.
|
||||
fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> {
|
||||
let field = path.rsplit('/').next().unwrap_or_default();
|
||||
let words: &[&str] = match field {
|
||||
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"projection" => &[
|
||||
if path == "/projection" && kind == RecordKind::Diagram {
|
||||
let mut choices = [
|
||||
"Overview",
|
||||
"Decomposition",
|
||||
"Allocation",
|
||||
"Traceability",
|
||||
"FunctionalDataflow",
|
||||
"Capabilities",
|
||||
],
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| (friendly(name), json!(name)))
|
||||
.collect::<Vec<_>>();
|
||||
choices.push((
|
||||
"State machine".into(),
|
||||
if value.get("StateMachine").is_some() {
|
||||
value.clone()
|
||||
} else {
|
||||
json!({"StateMachine": {"machine": Id::nil()}})
|
||||
},
|
||||
));
|
||||
return choices;
|
||||
}
|
||||
let words: &[&str] = match field {
|
||||
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
|
||||
"viewpoint" if path.contains("/Oa/") => &[
|
||||
"OperationalActivity",
|
||||
@@ -1515,6 +1549,162 @@ mod tests {
|
||||
reference_ids(draft, project, path)
|
||||
}
|
||||
|
||||
fn state_diagram_form_fixture() -> Project {
|
||||
language::parse(
|
||||
r#"arcadia 3; project state_forms "State forms" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
oa_region nested "Nested" { parent @ready; }
|
||||
oa_state leaf "Leaf" { parent @nested; }
|
||||
oa_state_machine other "Other modes" { parent @crew; }
|
||||
oa_region other_region "Other region" { parent @other; }
|
||||
oa_state foreign "Foreign" { parent @other_region; }
|
||||
sa_system system "System" {}
|
||||
sa_state_machine sa_modes "System modes" { parent @system; }
|
||||
diagram overview "Overview" {}
|
||||
diagram states "States" {
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
diagram other_states "Other states" {
|
||||
projection { StateMachine { machine @other; }; };
|
||||
}
|
||||
diagram duplicate "Duplicate" {
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
node ready_view { diagram @states; element @ready; }
|
||||
node region_view { diagram @states; element @main; }
|
||||
node duplicate_view { diagram @duplicate; element @ready; }
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn alias_id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| *candidate == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_projection_form_keeps_string_variants_and_requires_a_typed_context() {
|
||||
let project = state_diagram_form_fixture();
|
||||
let diagram = alias_id(&project, "overview");
|
||||
let machine = alias_id(&project, "modes");
|
||||
let mut draft = Draft::from_project(&project, diagram).unwrap();
|
||||
let choices = variants("/projection", &draft.value["projection"], draft.kind);
|
||||
assert_eq!(choices.len(), 7);
|
||||
for (_, value) in &choices[..6] {
|
||||
let decoded = serde_json::from_value::<ProjectionKind>(value.clone()).unwrap();
|
||||
assert!(value.is_string());
|
||||
assert_eq!(json!(decoded), *value);
|
||||
}
|
||||
draft.shape("/projection", choices[6].1.clone()).unwrap();
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
let path = "/projection/StateMachine/machine";
|
||||
assert!(reference(path));
|
||||
assert!(!optional_reference(path));
|
||||
assert_eq!(
|
||||
ids(&draft, &project, path)
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([machine, alias_id(&project, "other")])
|
||||
);
|
||||
draft.shape(path, json!(machine)).unwrap();
|
||||
let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
|
||||
assert_eq!(
|
||||
accepted.diagrams[&diagram].projection.state_machine(),
|
||||
Some(machine)
|
||||
);
|
||||
assert_eq!(accepted.elements, project.elements);
|
||||
assert!(accepted.diagrams[&diagram].nodes.is_empty());
|
||||
assert_eq!(
|
||||
variants("/projection", &draft.value["projection"], draft.kind)[6].1,
|
||||
json!({"StateMachine": {"machine": machine}})
|
||||
);
|
||||
|
||||
draft.shape("/layer", json!("Sa")).unwrap();
|
||||
assert_eq!(
|
||||
ids(&draft, &project, path),
|
||||
vec![alias_id(&project, "sa_modes")]
|
||||
);
|
||||
assert!(
|
||||
reference_choices(&draft, &project, path, machine)
|
||||
.iter()
|
||||
.any(|choice| {
|
||||
choice.id == machine && choice.label.starts_with("Current choice needs review")
|
||||
})
|
||||
);
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap().pointer(path),
|
||||
Some(&json!(machine))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_placement_form_uses_draft_destination_membership_without_changing_other_views() {
|
||||
let project = state_diagram_form_fixture();
|
||||
let node = alias_id(&project, "ready_view");
|
||||
let mut draft = Draft::from_project(&project, node).unwrap();
|
||||
let offered = ids(&draft, &project, "/element");
|
||||
for alias in ["modes", "ready", "nested", "leaf"] {
|
||||
assert!(offered.contains(&alias_id(&project, alias)), "{alias}");
|
||||
}
|
||||
for alias in [
|
||||
"main",
|
||||
"other",
|
||||
"other_region",
|
||||
"foreign",
|
||||
"crew",
|
||||
"sa_modes",
|
||||
] {
|
||||
assert!(!offered.contains(&alias_id(&project, alias)), "{alias}");
|
||||
}
|
||||
assert_eq!(
|
||||
ids(&draft, &project, "/diagram")
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>(),
|
||||
project.diagrams.keys().copied().collect::<BTreeSet<_>>()
|
||||
);
|
||||
|
||||
// A destination must remain selectable before its compatible element;
|
||||
// the current element is retained visibly until the draft is complete.
|
||||
|
||||
draft
|
||||
.shape("/diagram", json!(alias_id(&project, "other_states")))
|
||||
.unwrap();
|
||||
let ready = alias_id(&project, "ready");
|
||||
assert!(!ids(&draft, &project, "/element").contains(&ready));
|
||||
assert!(
|
||||
reference_choices(&draft, &project, "/element", ready)
|
||||
.iter()
|
||||
.any(|choice| {
|
||||
choice.id == ready && choice.label.starts_with("Current choice needs review")
|
||||
})
|
||||
);
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
draft
|
||||
.shape("/element", json!(alias_id(&project, "foreign")))
|
||||
.unwrap();
|
||||
let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
|
||||
assert!(
|
||||
accepted.diagrams[&alias_id(&project, "other_states")]
|
||||
.nodes
|
||||
.contains_key(&node)
|
||||
);
|
||||
|
||||
draft
|
||||
.shape("/diagram", json!(alias_id(&project, "overview")))
|
||||
.unwrap();
|
||||
assert!(ids(&draft, &project, "/element").contains(&alias_id(&project, "crew")));
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_allocation_offers_other_performers_without_counting_itself_twice() {
|
||||
let mut p = Project::empty();
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
//! The public state-view representation is shared by the language, REST and UI.
|
||||
use oa_core::{Command, Id, Kind, Layer, Project, ProjectionKind, language, validate_candidate};
|
||||
use oa_server::{
|
||||
app,
|
||||
store::{Store, StoredProject},
|
||||
};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::Path, sync::Arc};
|
||||
use tokio::{sync::oneshot, task::JoinHandle};
|
||||
|
||||
struct Server {
|
||||
base: String,
|
||||
client: Client,
|
||||
stop: oneshot::Sender<()>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
async fn start(path: &Path) -> Self {
|
||||
let path = path.to_owned();
|
||||
let store = tokio::task::spawn_blocking(move || Store::open(path))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}/api/v1", listener.local_addr().unwrap());
|
||||
let (stop, done) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
axum::serve(listener, app(Arc::new(store), None))
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
Self {
|
||||
base,
|
||||
client: Client::new(),
|
||||
stop,
|
||||
task,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(&self, id: Id) -> StoredProject {
|
||||
self.client
|
||||
.get(format!("{}/projects/{id}", self.base))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn edit(&self, current: &StoredProject, projection: Value) -> reqwest::Response {
|
||||
let diagram = current.model.diagrams.values().next().unwrap();
|
||||
let mut record = json!(diagram);
|
||||
record["projection"] = projection;
|
||||
self.client
|
||||
.post(format!(
|
||||
"{}/projects/{}/transactions",
|
||||
self.base, current.resource_id
|
||||
))
|
||||
.header("if-match", current.etag())
|
||||
.json(&json!({"commands":[{"EditDiagram":[diagram.id, record]}],"dry_run":false}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn stop(self) {
|
||||
let _ = self.stop.send(());
|
||||
drop(self.client);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), self.task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn documented_model() -> Project {
|
||||
// Parse the actual complete example, so an invalid published constructor or
|
||||
// reference cannot drift away from the server's accepted representation.
|
||||
let contract = include_str!("../../../docs/language.rst");
|
||||
let example = contract
|
||||
.split("This complete source example places one state from an existing machine:")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(".. code-block:: text")
|
||||
.nth(1)
|
||||
.unwrap();
|
||||
let source = example
|
||||
.lines()
|
||||
.skip_while(|line| line.trim().is_empty())
|
||||
.take_while(|line| line.starts_with(" ") || line.is_empty())
|
||||
.map(|line| line.strip_prefix(" ").unwrap_or(line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let model = language::parse(&source).unwrap();
|
||||
validate_candidate(&model).unwrap();
|
||||
model
|
||||
}
|
||||
|
||||
fn assert_pair(current: &StoredProject) {
|
||||
use oa_core::source_project::{ProjectLimits, compile_bundle};
|
||||
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 state_view_source_json_transactions_and_restart_share_the_published_contract() {
|
||||
let mut model = documented_model();
|
||||
let machine = id(&model, "modes");
|
||||
let crew = id(&model, "crew");
|
||||
let other = model
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Other modes", Some(crew))
|
||||
.unwrap();
|
||||
let system = model
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let wrong_layer = model
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "System modes", Some(system))
|
||||
.unwrap();
|
||||
let diagram = id(&model, "mode_view");
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = Server::start(directory.path()).await;
|
||||
let response = server
|
||||
.client
|
||||
.post(format!("{}/projects/import", server.base))
|
||||
.json(&model)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let imported: Value = response.json().await.unwrap();
|
||||
let resource = imported["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
let original = server.get(resource).await;
|
||||
assert_eq!(original.model, model);
|
||||
assert_pair(&original);
|
||||
|
||||
let source = server
|
||||
.client
|
||||
.get(format!("{}/projects/{resource}/source", server.base))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.contains("machine @modes;"));
|
||||
assert_eq!(language::parse(&source).unwrap(), model);
|
||||
let response = server
|
||||
.client
|
||||
.post(format!("{}/projects/import", server.base))
|
||||
.header("content-type", "text/plain")
|
||||
.body(source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let imported: Value = response.json().await.unwrap();
|
||||
let source_resource = imported["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
assert_eq!(server.get(source_resource).await.model, model);
|
||||
|
||||
for malformed in [
|
||||
json!("StateMachine"),
|
||||
json!({"StateMachine":{}}),
|
||||
json!({"StateMachine":{"machine":null}}),
|
||||
json!({"StateMachine":{"machine":"not-a-uuid"}}),
|
||||
json!({"StateMachine":{"machine":machine,"extra":1}}),
|
||||
json!({"StateMachine":{"machine":machine},"Overview":null}),
|
||||
] {
|
||||
assert_eq!(
|
||||
server.edit(&original, malformed.clone()).await.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"{malformed}"
|
||||
);
|
||||
}
|
||||
for (context, code) in [
|
||||
(Id::nil(), "state_diagram_context"),
|
||||
(crew, "state_diagram_context"),
|
||||
(wrong_layer, "state_diagram_context"),
|
||||
(other, "state_diagram_member"),
|
||||
] {
|
||||
let response = server
|
||||
.edit(&original, json!({"StateMachine":{"machine":context}}))
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let problem: Value = response.json().await.unwrap();
|
||||
assert_eq!(problem["code"], "command_rejected");
|
||||
// Commands report the shared validator's first failure as detail.
|
||||
// Candidate validation provides the structured diagnostic collection.
|
||||
let mut candidate = original.model.clone();
|
||||
candidate.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
ProjectionKind::StateMachine { machine: context };
|
||||
let checked = server
|
||||
.client
|
||||
.post(format!("{}/projects/{resource}/validation", server.base))
|
||||
.json(&json!({"format":"json","model":candidate}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(checked.status(), StatusCode::OK);
|
||||
let checked: Value = checked.json().await.unwrap();
|
||||
assert_eq!(checked["valid"], false);
|
||||
assert!(
|
||||
checked["issues"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|issue| issue["code"] == code),
|
||||
"{checked}"
|
||||
);
|
||||
}
|
||||
let unchanged = server.get(resource).await;
|
||||
assert_eq!(json!(unchanged), json!(original));
|
||||
|
||||
// A context change and removal of incompatible placements is one accepted
|
||||
// diagram edit; no semantic record or implied replacement placement appears.
|
||||
let mut changed = model.diagrams[&diagram].clone();
|
||||
changed.projection = ProjectionKind::StateMachine { machine: other };
|
||||
changed.nodes.clear();
|
||||
let response = server
|
||||
.client
|
||||
.post(format!("{}/projects/{resource}/transactions", server.base))
|
||||
.header("if-match", original.etag())
|
||||
.json(&json!({"commands":[Command::EditDiagram(diagram, changed.clone())],"dry_run":false}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let current = server.get(resource).await;
|
||||
assert_eq!(current.revision, original.revision + 1);
|
||||
assert_eq!(current.model.diagrams[&diagram], changed);
|
||||
assert_eq!(current.model.elements, model.elements);
|
||||
assert_pair(¤t);
|
||||
|
||||
// Source writes use the identical context reference and conditional commit.
|
||||
let source = language::format(¤t.model);
|
||||
let invalid = source.replace("machine @other_modes;", "machine @crew;");
|
||||
assert_ne!(invalid, source);
|
||||
let response = server
|
||||
.client
|
||||
.put(format!("{}/projects/{resource}/source", server.base))
|
||||
.header("if-match", current.etag())
|
||||
.header("content-type", "text/plain")
|
||||
.body(invalid)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(json!(server.get(resource).await), json!(current));
|
||||
let response = server
|
||||
.client
|
||||
.put(format!("{}/projects/{resource}/source", server.base))
|
||||
.header("if-match", current.etag())
|
||||
.header("content-type", "text/plain")
|
||||
.body(source.replace("machine @other_modes;", "machine @modes;"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let final_state = server.get(resource).await;
|
||||
assert_eq!(final_state.revision, current.revision + 1);
|
||||
assert_eq!(
|
||||
final_state.model.diagrams[&diagram]
|
||||
.projection
|
||||
.state_machine(),
|
||||
Some(machine)
|
||||
);
|
||||
assert!(final_state.model.diagrams[&diagram].nodes.is_empty());
|
||||
assert_pair(&final_state);
|
||||
server.stop().await;
|
||||
let server = Server::start(directory.path()).await;
|
||||
assert_eq!(json!(server.get(resource).await), json!(final_state));
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn served_state_projection_schema_preserves_closed_string_and_payload_variants() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = Server::start(directory.path()).await;
|
||||
let spec: Value = server
|
||||
.client
|
||||
.get(format!("{}/openapi.json", server.base))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let schemas = &spec["components"]["schemas"];
|
||||
assert_eq!(
|
||||
schemas["Diagram"]["properties"]["projection"]["$ref"],
|
||||
"#/components/schemas/ProjectionKind"
|
||||
);
|
||||
let variants = schemas["ProjectionKind"]["oneOf"].as_array().unwrap();
|
||||
assert_eq!(variants.len(), 2);
|
||||
let strings = variants[0]["enum"].as_array().unwrap();
|
||||
assert_eq!(strings.len(), 6);
|
||||
for value in strings {
|
||||
assert_eq!(
|
||||
json!(serde_json::from_value::<ProjectionKind>(value.clone()).unwrap()),
|
||||
*value
|
||||
);
|
||||
}
|
||||
assert!(!strings.contains(&json!("StateMachine")));
|
||||
assert_eq!(variants[1]["required"], json!(["StateMachine"]));
|
||||
assert_eq!(variants[1]["additionalProperties"], false);
|
||||
let payload = &variants[1]["properties"]["StateMachine"];
|
||||
assert_eq!(payload["required"], json!(["machine"]));
|
||||
assert_eq!(payload["additionalProperties"], false);
|
||||
assert_eq!(payload["properties"]["machine"]["type"], "string");
|
||||
assert_eq!(payload["properties"]["machine"]["format"], "uuid");
|
||||
for example in schemas["ProjectionKind"]["examples"].as_array().unwrap() {
|
||||
assert_eq!(
|
||||
json!(serde_json::from_value::<ProjectionKind>(example.clone()).unwrap()),
|
||||
*example
|
||||
);
|
||||
}
|
||||
server.stop().await;
|
||||
}
|
||||
+32
-2
@@ -987,8 +987,9 @@ reinterpret old mixed lifelines as a strict new viewpoint.</p>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}</pre>
|
||||
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> even
|
||||
if its candidate is invalid. A malformed request envelope or JSON model record
|
||||
is a 400. Source diagnostics include one-based line and column according to the
|
||||
if its candidate is invalid, including a model record that fails typed decoding.
|
||||
Malformed request JSON or an invalid format/payload envelope is a 400.
|
||||
Source diagnostics include one-based line and column according to the
|
||||
shared language parser. Candidate validation does not replace current state.
|
||||
Source-project validation locations refer to the submitted bytes before UUID
|
||||
insertion. Successful source-project PUT diagnostics refer to the returned
|
||||
@@ -1018,6 +1019,35 @@ introduce a completion transition. Invalid typed references reject the whole
|
||||
candidate and preserve revision/ETag. All writes retain the same conditional
|
||||
commit and disk persistence contract as other semantic records.</p>
|
||||
</section>
|
||||
<section id="state-diagram-representation">
|
||||
<h2>State diagram representation</h2>
|
||||
<p><span class="docutils literal">Diagram.projection</span> keeps the existing string values <span class="docutils literal">Overview</span>,
|
||||
<span class="docutils literal">Decomposition</span>, <span class="docutils literal">Allocation</span>, <span class="docutils literal">Traceability</span>, <span class="docutils literal">FunctionalDataflow</span> and
|
||||
<span class="docutils literal">Capabilities</span>. A dedicated state view instead uses the required payload:</p>
|
||||
<pre class="literal-block">{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}</pre>
|
||||
<p>The UUID MUST identify an existing StateMachine in the diagram's layer.
|
||||
Only that machine, its Regions and its state vertices may be placed, including
|
||||
nested members. Empty and partial views are valid. No machine, Region, vertex
|
||||
or placement is synthesized by accepting or reading a diagram. Source uses
|
||||
<span class="docutils literal">projection { StateMachine { machine @modes; }; };</span> with the same reference.</p>
|
||||
<p>Use the ordinary <span class="docutils literal">CreateDiagram</span> or <span class="docutils literal">EditDiagram</span> transaction commands,
|
||||
JSON model import/replacement, or source/source-project writes. A bare
|
||||
<span class="docutils literal">"StateMachine"</span>, missing or null <span class="docutils literal">machine</span>, malformed UUID, or additional
|
||||
payload field is malformed JSON model data (400). A missing, wrong-kind or
|
||||
cross-layer machine produces the semantic issue <span class="docutils literal">state_diagram_context</span>;
|
||||
an incompatible placement produces <span class="docutils literal">state_diagram_member</span>. These reject
|
||||
writes with 422. Transaction rejections use the <span class="docutils literal">command_rejected</span> problem
|
||||
code and include the first core failure in <span class="docutils literal">detail</span>; candidate validation
|
||||
provides the structured <span class="docutils literal">issues</span> collection.
|
||||
Source syntax errors use the source rejection response (422) with locations.
|
||||
The validation endpoint returns 200 with <span class="docutils literal">valid:false</span> for a well-formed
|
||||
invalid candidate, as described above.</p>
|
||||
<p>When changing a diagram's machine or selecting StateMachine for an existing
|
||||
diagram, remove or relocate incompatible placements in the same transaction.
|
||||
Other projections continue to preserve hidden placements. Rejected writes
|
||||
retain the accepted model, source bundle, revision and ETag. Reading a state
|
||||
view has no semantic or persistence side effects.</p>
|
||||
</section>
|
||||
<section id="cross-layer-transitions">
|
||||
<h2>Cross-layer transitions</h2>
|
||||
<p>Preview accepts the shared <span class="docutils literal">TransitionRequest</span> record, including direction
|
||||
|
||||
+37
-2
@@ -386,8 +386,9 @@ or::
|
||||
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}
|
||||
|
||||
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
|
||||
if its candidate is invalid. A malformed request envelope or JSON model record
|
||||
is a 400. Source diagnostics include one-based line and column according to the
|
||||
if its candidate is invalid, including a model record that fails typed decoding.
|
||||
Malformed request JSON or an invalid format/payload envelope is a 400.
|
||||
Source diagnostics include one-based line and column according to the
|
||||
shared language parser. Candidate validation does not replace current state.
|
||||
Source-project validation locations refer to the submitted bytes before UUID
|
||||
insertion. Successful source-project PUT diagnostics refer to the returned
|
||||
@@ -420,6 +421,40 @@ introduce a completion transition. Invalid typed references reject the whole
|
||||
candidate and preserve revision/ETag. All writes retain the same conditional
|
||||
commit and disk persistence contract as other semantic records.
|
||||
|
||||
State diagram representation
|
||||
----------------------------
|
||||
|
||||
``Diagram.projection`` keeps the existing string values ``Overview``,
|
||||
``Decomposition``, ``Allocation``, ``Traceability``, ``FunctionalDataflow`` and
|
||||
``Capabilities``. A dedicated state view instead uses the required payload::
|
||||
|
||||
{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}
|
||||
|
||||
The UUID MUST identify an existing StateMachine in the diagram's layer.
|
||||
Only that machine, its Regions and its state vertices may be placed, including
|
||||
nested members. Empty and partial views are valid. No machine, Region, vertex
|
||||
or placement is synthesized by accepting or reading a diagram. Source uses
|
||||
``projection { StateMachine { machine @modes; }; };`` with the same reference.
|
||||
|
||||
Use the ordinary ``CreateDiagram`` or ``EditDiagram`` transaction commands,
|
||||
JSON model import/replacement, or source/source-project writes. A bare
|
||||
``"StateMachine"``, missing or null ``machine``, malformed UUID, or additional
|
||||
payload field is malformed JSON model data (400). A missing, wrong-kind or
|
||||
cross-layer machine produces the semantic issue ``state_diagram_context``;
|
||||
an incompatible placement produces ``state_diagram_member``. These reject
|
||||
writes with 422. Transaction rejections use the ``command_rejected`` problem
|
||||
code and include the first core failure in ``detail``; candidate validation
|
||||
provides the structured ``issues`` collection.
|
||||
Source syntax errors use the source rejection response (422) with locations.
|
||||
The validation endpoint returns 200 with ``valid:false`` for a well-formed
|
||||
invalid candidate, as described above.
|
||||
|
||||
When changing a diagram's machine or selecting StateMachine for an existing
|
||||
diagram, remove or relocate incompatible placements in the same transaction.
|
||||
Other projections continue to preserve hidden placements. Rejected writes
|
||||
retain the accepted model, source bundle, revision and ETag. Reading a state
|
||||
view has no semantic or persistence side effects.
|
||||
|
||||
Cross-layer transitions
|
||||
-----------------------
|
||||
|
||||
|
||||
+36
-6
@@ -1285,17 +1285,47 @@ Studio profile; no running history state is persisted or synthesized.</p>
|
||||
<h2>5. Diagrams, artifacts, and cross-layer traceability</h2>
|
||||
<p><span class="docutils literal">diagram</span> has name, identity, alias, description, <span class="docutils literal">layer</span> (Oa by default),
|
||||
<span class="docutils literal">projection</span> (Overview by default), and <span class="docutils literal">routes</span> (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.</p>
|
||||
The string projections are Overview, Decomposition, Allocation,
|
||||
FunctionalDataflow, Capabilities, and Traceability. StateMachine is a payload
|
||||
constructor with one required <span class="docutils literal">machine</span> reference. Traceability diagrams
|
||||
can show both layers; other diagrams show their chosen layer.</p>
|
||||
<p>Allocation is an architecture view: it shows participants and work, nests
|
||||
allocated work under its performer, and displays interactions, functional
|
||||
exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy
|
||||
and its exchanges. Capabilities shows participants, roles, missions and
|
||||
capabilities with their involvement, exploitation, include, extend and
|
||||
generalization links. Decomposition and Overview retain general containment;
|
||||
Overview also exposes all relationships. Changing the projection hides
|
||||
Overview also exposes all relationships. These string projections hide
|
||||
inapplicable placements without deleting them or changing semantic records.</p>
|
||||
<p>A StateMachine projection MUST reference an existing StateMachine in the
|
||||
diagram's layer. Its placements MUST refer to that machine, its Regions, or
|
||||
its state vertices, including nested Regions and vertices. Events, other
|
||||
machines and their members, and unrelated semantic elements are not valid
|
||||
placements in this view. Empty and partial state views are valid; neither
|
||||
creating nor reading a view synthesizes semantic elements or placements.
|
||||
Changing an existing diagram to StateMachine, or changing its machine, MUST
|
||||
remove or relocate incompatible placements in the same transaction. Context
|
||||
errors use <span class="docutils literal">state_diagram_context</span>; incompatible placements use
|
||||
<span class="docutils literal">state_diagram_member</span>. Neither case silently hides invalid members.</p>
|
||||
<p>This complete source example places one state from an existing machine:</p>
|
||||
<pre class="code text literal-block"><code>arcadia 3;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main region" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
diagram mode_view "Operating modes" {
|
||||
layer Oa;
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
node ready_view { diagram @mode_view; element @ready; }
|
||||
}</code></pre>
|
||||
<p>The JSON projection is
|
||||
<span class="docutils literal"><span class="pre">{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}</span></span>,
|
||||
where the UUID is the referenced machine's identity. A bare <span class="docutils literal">"StateMachine"</span>,
|
||||
a missing or null <span class="docutils literal">machine</span>, an invalid UUID, or additional payload fields
|
||||
is malformed. Existing projections retain their string representation, for
|
||||
example <span class="docutils literal">"Overview"</span> in JSON and <span class="docutils literal">projection Overview;</span> in source.</p>
|
||||
<p>Ports are boundary anchors of visible owners, with their existing
|
||||
semantic identity. An explicitly placed port is not rendered as a separate
|
||||
large node. Functional exchanges use their declared source/target ports;
|
||||
@@ -1312,8 +1342,8 @@ allocation, or realization. Hiding an element from a diagram must remain
|
||||
distinguishable from deleting it from the model.
|
||||
Changing a node's diagram preserves the placement identity, alias, coordinates,
|
||||
size, collapse state, manual port anchors, and attached artifacts. The destination
|
||||
must accept the element's layer and must not already contain another placement
|
||||
of that element.</p>
|
||||
must accept the element's layer, satisfy StateMachine membership when applicable,
|
||||
and must not already contain another placement of that element.</p>
|
||||
<p>Each <span class="docutils literal">port_anchors</span> entry is a PortAnchor record with required <span class="docutils literal">port</span>
|
||||
reference, <span class="docutils literal">side</span> (Left, Right, Top, or Bottom), and <span class="docutils literal">offset</span> (finite
|
||||
binary32 from 0 through 1). It has no separate identity or alias. The port MUST
|
||||
|
||||
+42
-6
@@ -625,9 +625,10 @@ Studio profile; no running history state is persisted or synthesized.
|
||||
|
||||
``diagram`` has name, identity, alias, description, ``layer`` (Oa by default),
|
||||
``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.
|
||||
The string projections are Overview, Decomposition, Allocation,
|
||||
FunctionalDataflow, Capabilities, and Traceability. StateMachine is a payload
|
||||
constructor with one required ``machine`` reference. Traceability diagrams
|
||||
can show both layers; other diagrams show their chosen layer.
|
||||
|
||||
Allocation is an architecture view: it shows participants and work, nests
|
||||
allocated work under its performer, and displays interactions, functional
|
||||
@@ -635,9 +636,44 @@ exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy
|
||||
and its exchanges. Capabilities shows participants, roles, missions and
|
||||
capabilities with their involvement, exploitation, include, extend and
|
||||
generalization links. Decomposition and Overview retain general containment;
|
||||
Overview also exposes all relationships. Changing the projection hides
|
||||
Overview also exposes all relationships. These string projections hide
|
||||
inapplicable placements without deleting them or changing semantic records.
|
||||
|
||||
A StateMachine projection MUST reference an existing StateMachine in the
|
||||
diagram's layer. Its placements MUST refer to that machine, its Regions, or
|
||||
its state vertices, including nested Regions and vertices. Events, other
|
||||
machines and their members, and unrelated semantic elements are not valid
|
||||
placements in this view. Empty and partial state views are valid; neither
|
||||
creating nor reading a view synthesizes semantic elements or placements.
|
||||
Changing an existing diagram to StateMachine, or changing its machine, MUST
|
||||
remove or relocate incompatible placements in the same transaction. Context
|
||||
errors use ``state_diagram_context``; incompatible placements use
|
||||
``state_diagram_member``. Neither case silently hides invalid members.
|
||||
|
||||
This complete source example places one state from an existing machine:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 3;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main region" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
diagram mode_view "Operating modes" {
|
||||
layer Oa;
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
node ready_view { diagram @mode_view; element @ready; }
|
||||
}
|
||||
|
||||
The JSON projection is
|
||||
``{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}``,
|
||||
where the UUID is the referenced machine's identity. A bare ``"StateMachine"``,
|
||||
a missing or null ``machine``, an invalid UUID, or additional payload fields
|
||||
is malformed. Existing projections retain their string representation, for
|
||||
example ``"Overview"`` in JSON and ``projection Overview;`` in source.
|
||||
|
||||
Ports are boundary anchors of visible owners, with their existing
|
||||
semantic identity. An explicitly placed port is not rendered as a separate
|
||||
large node. Functional exchanges use their declared source/target ports;
|
||||
@@ -655,8 +691,8 @@ allocation, or realization. Hiding an element from a diagram must remain
|
||||
distinguishable from deleting it from the model.
|
||||
Changing a node's diagram preserves the placement identity, alias, coordinates,
|
||||
size, collapse state, manual port anchors, and attached artifacts. The destination
|
||||
must accept the element's layer and must not already contain another placement
|
||||
of that element.
|
||||
must accept the element's layer, satisfy StateMachine membership when applicable,
|
||||
and must not already contain another placement of that element.
|
||||
|
||||
Each ``port_anchors`` entry is a PortAnchor record with required ``port``
|
||||
reference, ``side`` (Left, Right, Top, or Bottom), and ``offset`` (finite
|
||||
|
||||
+48
-9
@@ -3685,6 +3685,53 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProjectionKind": {
|
||||
"description": "Existing projections remain strings. StateMachine carries one required UUID reference to an existing StateMachine in the diagram layer (state_diagram_context). Its placements may contain only that machine and its regions/state vertices, including nested members (state_diagram_member). Empty and partial views are valid. Incompatible placements must be removed or relocated in the same transaction when changing context; accepting or reading a view does not synthesize semantic records or placements.",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FunctionalDataflow",
|
||||
"Capabilities",
|
||||
"Overview",
|
||||
"Decomposition",
|
||||
"Allocation",
|
||||
"Traceability"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"StateMachine": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"machine": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Required identity of the same-layer StateMachine that owns this view's members."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"machine"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"StateMachine"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"examples": [
|
||||
"Overview",
|
||||
{
|
||||
"StateMachine": {
|
||||
"machine": "11111111-1111-4111-8111-111111111111"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Diagram": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3707,15 +3754,7 @@
|
||||
"$ref": "#/components/schemas/Layer"
|
||||
},
|
||||
"projection": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"FunctionalDataflow",
|
||||
"Capabilities",
|
||||
"Overview",
|
||||
"Decomposition",
|
||||
"Allocation",
|
||||
"Traceability"
|
||||
]
|
||||
"$ref": "#/components/schemas/ProjectionKind"
|
||||
},
|
||||
"nodes": {
|
||||
"type": "object",
|
||||
|
||||
Reference in New Issue
Block a user