diff --git a/crates/oa-server/tests/http.rs b/crates/oa-server/tests/http.rs index c4a15248..555e68b7 100644 --- a/crates/oa-server/tests/http.rs +++ b/crates/oa-server/tests/http.rs @@ -20,6 +20,8 @@ mod semantic_transitions; mod source6_views; #[path = "http/source7_contracts.rs"] mod source7_contracts; +#[path = "http/scenario_layout.rs"] +mod scenario_layout; #[path = "http/source_projects.rs"] mod source_projects; diff --git a/crates/oa-server/tests/http/scenario_layout.rs b/crates/oa-server/tests/http/scenario_layout.rs new file mode 100644 index 00000000..df543043 --- /dev/null +++ b/crates/oa-server/tests/http/scenario_layout.rs @@ -0,0 +1,344 @@ +//! Saved Scenario geometry uses the same atomic model/source HTTP transaction. +use super::*; +use oa_core::{ + Diagram, DiagramLayout, MessageKind, OccurrenceBody, OccurrenceEndpoint, ProjectionKind, + ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition, ScenarioViewpoint, SharedBody, + source_project::{ProjectLimits, SourceBundle, compile_bundle, format_bundle}, +}; +use std::collections::BTreeMap; + +struct Fixture { + bundle: SourceBundle, + views: [Diagram; 2], + lane: Id, + messages: [Id; 3], + foreign: Id, +} + +fn address(occurrence: Id) -> OccurrenceEndpoint { + OccurrenceEndpoint { + references: vec![], + occurrence, + } +} + +fn fixture() -> Fixture { + let mut model = Project::empty(); + let owner = model + .add_element(Kind::Scenario, Layer::Oa, "Delivery", None) + .unwrap(); + let other = model + .add_element(Kind::Scenario, Layer::Oa, "Other delivery", None) + .unwrap(); + for id in [owner, other] { + let Some(SharedBody::Scenario { viewpoint, .. }) = + model.elements.get_mut(&id).unwrap().shared_mut() + else { + panic!("Scenario body") + }; + *viewpoint = ScenarioViewpoint::ParticipantInteraction; + } + let sender = model + .add_element(Kind::Entity, Layer::Oa, "Sender", None) + .unwrap(); + let receiver = model + .add_element(Kind::Entity, Layer::Oa, "Receiver", None) + .unwrap(); + let lane = model + .add_element(Kind::Lifeline, Layer::Oa, "Sender lane", Some(owner)) + .unwrap(); + let destination = model + .add_element(Kind::Lifeline, Layer::Oa, "Receiver lane", Some(owner)) + .unwrap(); + for (id, represented) in [(lane, sender), (destination, receiver)] { + *model.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(represented), + }; + } + let exchange = model.relate( + RelationKind::CommunicationMean, + "Delivery channel", + sender, + receiver, + ); + let mut occurrences = Vec::new(); + for (name, parent, complete) in [ + ("First delivery", owner, true), + ("Repeated delivery", owner, true), + ("Unfinished delivery", owner, false), + ("Foreign delivery", other, false), + ] { + let id = model + .add_element(Kind::Occurrence, Layer::Oa, name, Some(parent)) + .unwrap(); + *model.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: complete.then_some(exchange), + sender: complete.then_some(lane), + receiver: complete.then_some(destination), + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + precondition: String::new(), + postcondition: String::new(), + }; + occurrences.push(id); + } + *model.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = occurrences[..3].to_vec(); + *model.elements.get_mut(&other).unwrap().steps_mut().unwrap() = vec![occurrences[3]]; + let views = ["delivery_wide", "delivery_review"].map(|alias| Diagram { + id: Id::new_v4(), + alias: alias.into(), + name: alias.into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: BTreeMap::new(), + routes: vec![], + layout: None, + }); + let mut bundle = format_bundle(&model); + let entry = bundle.files.get_mut(&bundle.entry).unwrap(); + *entry = format!( + "// Transport fixture rationale\n{}", + entry.replacen("{\n", "{\n include \"notes.arcadia\";\n", 1) + ); + bundle.files.insert( + "notes.arcadia".into(), + "// Unrelated rationale: 航海\r\n".into(), + ); + let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + assert_eq!(compiled.decoded.project, model); + assert!( + oa_core::validation::validate(&model) + .iter() + .all(|issue| issue.severity != oa_core::validation::Severity::Error) + ); + Fixture { + bundle: compiled.bundle, + views, + lane, + messages: [occurrences[0], occurrences[1], occurrences[2]], + foreign: occurrences[3], + } +} + +async fn post( + server: &TestServer, + current: &StoredProject, + etag: &str, + commands: Value, + dry_run: bool, +) -> (StatusCode, Option, Value) { + let response = server + .client + .post(server.url(&format!("/projects/{}/transactions", current.resource_id))) + .header("if-match", etag) + .json(&json!({"commands": commands, "dry_run": dry_run})) + .send() + .await + .unwrap(); + let status = response.status(); + let etag = response + .headers() + .get("etag") + .map(|value| value.to_str().unwrap().to_owned()); + (status, etag, response.json().await.unwrap()) +} + +async fn assert_unchanged(server: &TestServer, current: &StoredProject, disk: &Path, bytes: &[u8]) { + assert_eq!( + serde_json::to_value(server.get(current.resource_id).await).unwrap(), + serde_json::to_value(current).unwrap(), + "entire stored envelope, including Project, source and revision" + ); + assert_eq!( + std::fs::read(disk).unwrap(), + bytes, + "physical stored envelope must not be rewritten" + ); +} + +fn assert_source_pair(current: &StoredProject, notes: &str) { + let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap(); + assert_eq!(compiled.bundle, current.source_project); + assert_eq!(compiled.decoded.project, current.model); + assert_eq!(current.source_project.files["notes.arcadia"], notes); + assert!( + current.source_project.files[¤t.source_project.entry] + .starts_with("// Transport fixture rationale\n") + ); +} + +#[tokio::test] +async fn saved_scenario_positions_roundtrip_through_http_without_partial_or_noop_writes() { + let f = fixture(); + let directory = tempfile::tempdir().unwrap(); + let server = TestServer::start(directory.path(), None).await; + let response = server + .client + .post(server.url("/projects/import-source-project")) + .json(&f.bundle) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let summary: Value = response.json().await.unwrap(); + let id = summary["resource_id"].as_str().unwrap().parse().unwrap(); + let original = server.get(id).await; + assert_eq!(original.source_project, f.bundle); + let disk = directory.path().join(format!("{id}.json")); + let original_bytes = std::fs::read(&disk).unwrap(); + let [wide, review] = f.views.clone(); + // Raw JSON exercises the actual externally-tagged transport fields. Two + // messages deliberately share one engineering exchange but retain distinct IDs. + let commands = json!([ + {"CreateDiagram": wide}, {"CreateDiagram": review}, + {"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": -120.0}}}, + {"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[0]}, "send_y": 200.0}}}, + {"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[1]}, "send_y": 275.0}}}, + {"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[2]}, "send_y": -35.0}}}, + {"SetScenarioLanePosition": {"diagram": review.id, "position": {"lifeline": f.lane, "x": 760.0}}}, + {"SetScenarioMessagePosition": {"diagram": review.id, "position": {"address": {"references": [], "occurrence": f.messages[0]}, "send_y": 430.0}}} + ]); + // Independent expected Project: populate the specified records directly; + // do not use execute_batch/prepare_command as the success oracle. + let mut expected = original.model.clone(); + let mut expected_wide = wide.clone(); + expected_wide.layout = Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: vec![ScenarioLanePosition { + lifeline: f.lane, + x: -120.0, + }], + messages: f + .messages + .into_iter() + .zip([200., 275., -35.]) + .map(|(id, send_y)| ScenarioMessagePosition { + address: address(id), + send_y, + }) + .collect(), + })); + let mut expected_review = review.clone(); + expected_review.layout = Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: vec![ScenarioLanePosition { + lifeline: f.lane, + x: 760.0, + }], + messages: vec![ScenarioMessagePosition { + address: address(f.messages[0]), + send_y: 430.0, + }], + })); + expected.diagrams.insert(wide.id, expected_wide); + expected.diagrams.insert(review.id, expected_review.clone()); + let (status, etag, preview) = + post(&server, &original, &original.etag(), commands.clone(), true).await; + assert_eq!(status, StatusCode::OK, "{preview}"); + assert_eq!(etag, Some(original.etag())); + assert_eq!(preview["revision"], original.revision); + assert_eq!(preview["changed"], true); + assert_eq!( + serde_json::from_value::(preview["model"].clone()).unwrap(), + expected + ); + let preview_source: SourceBundle = + serde_json::from_value(preview["source_project"].clone()).unwrap(); + assert_eq!( + compile_bundle(&preview_source, ProjectLimits::default()) + .unwrap() + .decoded + .project, + expected + ); + assert_unchanged(&server, &original, &disk, &original_bytes).await; + let (status, etag, committed) = + post(&server, &original, &original.etag(), commands, false).await; + assert_eq!(status, StatusCode::OK, "{committed}"); + assert_eq!(committed["changed"], true); + let current = server.get(id).await; + assert_eq!(etag, Some(current.etag())); + assert_eq!(current.revision, original.revision + 1); + assert_eq!(current.model, expected); + assert_eq!( + current.source_project, preview_source, + "preview and commit must agree exactly" + ); + assert_source_pair(¤t, &f.bundle.files["notes.arcadia"]); + let current_bytes = std::fs::read(&disk).unwrap(); + let no_op = json!([ + {"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": -120.0}}}, + {"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[2]}, "send_y": -35.0}}} + ]); + for dry_run in [true, false] { + let (status, etag, result) = + post(&server, ¤t, ¤t.etag(), no_op.clone(), dry_run).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(etag, Some(current.etag())); + assert_eq!(result["changed"], false); + assert_eq!(result["revision"], current.revision); + assert_unchanged(&server, ¤t, &disk, ¤t_bytes).await; + } + // A stale request must fail even when its coordinate value is a no-op. + let (status, _, result) = post(&server, ¤t, &original.etag(), no_op, false).await; + assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{result}"); + assert_eq!(result["code"], "revision_conflict"); + assert_unchanged(&server, ¤t, &disk, ¤t_bytes).await; + // First command is valid. The second belongs to another Scenario; neither + // a dry-run nor a real transaction may expose/persist the partial candidate. + let invalid = json!([ + {"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": 999.0}}}, + {"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.foreign}, "send_y": 400.0}}} + ]); + for dry_run in [true, false] { + let (status, _, result) = + post(&server, ¤t, ¤t.etag(), invalid.clone(), dry_run).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{result}"); + assert_eq!(result["code"], "command_rejected"); + assert!(result.get("model").is_none()); + assert_unchanged(&server, ¤t, &disk, ¤t_bytes).await; + } + let mut removals = + vec![json!({"RemoveScenarioLanePosition": {"diagram": wide.id, "lifeline": f.lane}})]; + removals.extend(f.messages.map(|occurrence| json!({"RemoveScenarioMessagePosition": {"diagram": wide.id, "address": {"references": [], "occurrence": occurrence}}}))); + let removals = Value::Array(removals); + let (status, _, result) = + post(&server, ¤t, ¤t.etag(), removals.clone(), false).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["changed"], true); + let removed = server.get(id).await; + assert_eq!(removed.revision, current.revision + 1); + expected.diagrams.get_mut(&wide.id).unwrap().layout = + Some(DiagramLayout::Scenario(ScenarioLayout::default())); + assert_eq!( + removed.model, expected, + "removal changes only the addressed saved view" + ); + assert_eq!(removed.model.diagrams[&review.id], expected_review); + assert_source_pair(&removed, &f.bundle.files["notes.arcadia"]); + let removed_bytes = std::fs::read(&disk).unwrap(); + let (status, _, result) = post(&server, &removed, &removed.etag(), removals, false).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["changed"], false); + assert_unchanged(&server, &removed, &disk, &removed_bytes).await; + let exported = server + .client + .get(server.url(&format!("/projects/{id}/source-project"))) + .send() + .await + .unwrap(); + assert_eq!(exported.status(), StatusCode::OK); + assert_eq!(exported.headers()["etag"].to_str().unwrap(), removed.etag()); + assert_eq!( + exported.json::().await.unwrap(), + removed.source_project + ); + server.stop().await; + let restarted = TestServer::start(directory.path(), None).await; + assert_unchanged(&restarted, &removed, &disk, &removed_bytes).await; + restarted.stop().await; +}