345 lines
14 KiB
Rust
345 lines
14 KiB
Rust
//! 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<String>, 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::<Project>(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::<SourceBundle>().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;
|
|
}
|