//! 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::>() .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::(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::(example.clone()).unwrap()), *example ); } server.stop().await; }