Add state diagram form and representation contract parity

This commit is contained in:
2026-09-06 02:33:18 +03:00
parent 24595810a1
commit 53cd096a56
7 changed files with 737 additions and 38 deletions
+203 -13
View File
@@ -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(&current.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(&current);
// Source writes use the identical context reference and conditional commit.
let source = language::format(&current.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;
}