Exercise saved Scenario positions through HTTP transactions and restart

This commit is contained in:
2026-09-12 21:06:35 -07:00
parent 20acd3238e
commit bfb4b39744
2 changed files with 346 additions and 0 deletions
+2
View File
@@ -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(&current.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[&current.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(&current, &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, &current, &current.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, &current, &disk, &current_bytes).await;
}
// A stale request must fail even when its coordinate value is a no-op.
let (status, _, result) = post(&server, &current, &original.etag(), no_op, false).await;
assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{result}");
assert_eq!(result["code"], "revision_conflict");
assert_unchanged(&server, &current, &disk, &current_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, &current, &current.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, &current, &disk, &current_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, &current, &current.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;
}