Exercise saved Scenario positions through HTTP transactions and restart
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user