//! 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; }