Add state diagram form and representation contract parity
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user