522 lines
18 KiB
Rust
522 lines
18 KiB
Rust
use super::*;
|
|
use oa_core::{
|
|
ComponentPortKind, ComponentPortOrientation, Position, ProjectionKind, RelationBody,
|
|
SharedBody,
|
|
source_project::{ProjectLimits, SourceBundle, compile_bundle},
|
|
transitions::{
|
|
CommunicationEndpointDecision, CommunicationPortChoice, TransitionDirection,
|
|
TransitionMapping, TransitionRequest,
|
|
},
|
|
view_options::{
|
|
CategorySelection, EdgeKind, ExchangeFamily, ExchangeLabels, RelationKinds, ViewOptions,
|
|
},
|
|
};
|
|
use std::collections::BTreeMap;
|
|
|
|
struct Fixture {
|
|
bundle: SourceBundle,
|
|
mean: Id,
|
|
category: Id,
|
|
diagram: Id,
|
|
item: Id,
|
|
oa: [Id; 2],
|
|
sa: [Id; 2],
|
|
}
|
|
|
|
fn fixture() -> Fixture {
|
|
let mut model = Project::empty();
|
|
let a = model
|
|
.add_element(Kind::Entity, Layer::Oa, "Rescue team", None)
|
|
.unwrap();
|
|
let b = model
|
|
.add_element(Kind::Actor, Layer::Oa, "Control centre", None)
|
|
.unwrap();
|
|
let sa = model
|
|
.add_element(Kind::System, Layer::Sa, "Radio", None)
|
|
.unwrap();
|
|
let sb = model
|
|
.add_element(Kind::SystemActor, Layer::Sa, "External radio", None)
|
|
.unwrap();
|
|
let item = model
|
|
.add_element(Kind::ExchangeItem, Layer::Oa, "Position & status", None)
|
|
.unwrap();
|
|
let mean = model.relate(RelationKind::CommunicationMean, "Radio <channel>", a, b);
|
|
let category = model
|
|
.add_element(Kind::ExchangeCategory, Layer::Oa, "Radio channels", None)
|
|
.unwrap();
|
|
*model
|
|
.elements
|
|
.get_mut(&category)
|
|
.unwrap()
|
|
.shared_mut()
|
|
.unwrap() = SharedBody::ExchangeCategory {
|
|
family: ExchangeFamily::Participant,
|
|
exchanges: vec![mean],
|
|
};
|
|
let diagram = model.ensure_diagram(Layer::Oa);
|
|
model.place(diagram, a, Position { x: 0., y: 0. });
|
|
model.place(diagram, b, Position { x: 400., y: 0. });
|
|
let view = model.diagrams.get_mut(&diagram).unwrap();
|
|
view.projection = ProjectionKind::Allocation;
|
|
view.view_options = ViewOptions {
|
|
relation_kinds: RelationKinds::Only(vec![EdgeKind::Relation(
|
|
RelationKind::CommunicationMean,
|
|
)]),
|
|
participant_categories: CategorySelection::Any(vec![category]),
|
|
exchange_labels: ExchangeLabels::NameAndItems,
|
|
..Default::default()
|
|
};
|
|
let source = format!(
|
|
"// Authored source-6 engineering rationale\n{}",
|
|
oa_core::language::format(&model).replacen("{\n", "{\n include \"notes.arcadia\";\n", 1)
|
|
);
|
|
let bundle = SourceBundle {
|
|
entry: "radio.arcadia".into(),
|
|
files: BTreeMap::from([
|
|
("radio.arcadia".into(), source),
|
|
(
|
|
"notes.arcadia".into(),
|
|
"// Retain this exact included note, including CRLF.\r\n".into(),
|
|
),
|
|
]),
|
|
};
|
|
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
|
assert_eq!(compiled.decoded.project, model);
|
|
Fixture {
|
|
bundle: compiled.bundle,
|
|
mean,
|
|
category,
|
|
diagram,
|
|
item,
|
|
oa: [a, b],
|
|
sa: [sa, sb],
|
|
}
|
|
}
|
|
|
|
async fn import(server: &TestServer, bundle: &SourceBundle) -> StoredProject {
|
|
let response = server
|
|
.client
|
|
.post(server.url("/projects/import-source-project"))
|
|
.json(bundle)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let result: Value = response.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::CREATED, "{result}");
|
|
server
|
|
.get(result["resource_id"].as_str().unwrap().parse().unwrap())
|
|
.await
|
|
}
|
|
|
|
fn assert_pair(stored: &StoredProject) {
|
|
let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap();
|
|
assert_eq!(compiled.bundle, stored.source_project);
|
|
assert_eq!(compiled.decoded.project, stored.model);
|
|
assert_eq!(stored.model.schema_version, 7);
|
|
}
|
|
|
|
async fn transact(
|
|
server: &TestServer,
|
|
current: &StoredProject,
|
|
commands: Vec<Command>,
|
|
) -> reqwest::Response {
|
|
server
|
|
.client
|
|
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
|
|
.header("if-match", current.etag())
|
|
.json(&json!({"commands":commands}))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn source6_http_views_categories_and_means_preserve_source_and_reject_invalid_batch_after_restart()
|
|
{
|
|
let f = fixture();
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let original = import(&server, &f.bundle).await;
|
|
let resource = original.resource_id;
|
|
assert_eq!(original.source_project, f.bundle);
|
|
assert_pair(&original);
|
|
let model: Value = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{resource}/model")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.error_for_status()
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
model["relations"][f.mean.to_string()]["body"]["CommunicationMean"]["oriented"],
|
|
false
|
|
);
|
|
assert_eq!(
|
|
model["diagrams"][f.diagram.to_string()]["view_options"]["exchange_labels"],
|
|
"NameAndItems"
|
|
);
|
|
for (route, expected) in [
|
|
("relations?kind=CommunicationMean", f.mean),
|
|
("elements?kind=ExchangeCategory", f.category),
|
|
] {
|
|
let result: Value = server
|
|
.client
|
|
.get(server.url(&format!("/projects/{resource}/{route}")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.error_for_status()
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
|
assert_eq!(result["items"][0]["id"], expected.to_string());
|
|
}
|
|
// There is no projection HTTP route. The actual returned model supplies the
|
|
// shared desktop projection; saved filters must not alter queried relations.
|
|
assert!(
|
|
oa_core::projection::project_view(&original.model, f.diagram)
|
|
.edges
|
|
.iter()
|
|
.any(|e| e.id == f.mean)
|
|
);
|
|
let mut relation = original.model.relations[&f.mean].clone();
|
|
if let RelationBody::CommunicationMean {
|
|
oriented, items, ..
|
|
} = &mut relation.body
|
|
{
|
|
*oriented = true;
|
|
*items = vec![f.item];
|
|
}
|
|
let mut diagram = original.model.diagrams[&f.diagram].clone();
|
|
diagram.view_options.hidden_edges = vec![f.mean];
|
|
diagram.view_options.exchange_labels = ExchangeLabels::Items;
|
|
let response = transact(
|
|
&server,
|
|
&original,
|
|
vec![
|
|
Command::EditRelation(f.mean, relation.clone()),
|
|
Command::EditDiagram(f.diagram, diagram.clone()),
|
|
],
|
|
)
|
|
.await;
|
|
let status = response.status();
|
|
let result: Value = response.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::OK, "{result}");
|
|
let changed = server.get(resource).await;
|
|
assert_eq!(changed.revision, original.revision + 1);
|
|
assert_eq!(changed.model.relations[&f.mean], relation);
|
|
assert_eq!(changed.model.diagrams[&f.diagram], diagram);
|
|
assert!(
|
|
oa_core::projection::project_view(&changed.model, f.diagram)
|
|
.edges
|
|
.is_empty()
|
|
);
|
|
assert_eq!(
|
|
changed.source_project.files["notes.arcadia"],
|
|
f.bundle.files["notes.arcadia"]
|
|
);
|
|
assert!(
|
|
changed.source_project.files["radio.arcadia"]
|
|
.starts_with("// Authored source-6 engineering rationale\n")
|
|
);
|
|
assert_pair(&changed);
|
|
let disk = directory.path().join(format!("{resource}.json"));
|
|
let accepted_bytes = std::fs::read(&disk).unwrap();
|
|
let mut invalid = changed.model.elements[&f.category].clone();
|
|
if let Some(SharedBody::ExchangeCategory { family, .. }) = invalid.shared_mut() {
|
|
*family = ExchangeFamily::Work; // A communication means cannot join this family.
|
|
}
|
|
relation.name = "Must not leak from rejected batch".into();
|
|
let rejected = transact(
|
|
&server,
|
|
&changed,
|
|
vec![
|
|
Command::EditRelation(f.mean, relation),
|
|
Command::EditElement(f.category, invalid),
|
|
],
|
|
)
|
|
.await;
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
let retained = server.get(resource).await;
|
|
assert_eq!(retained.model, changed.model);
|
|
assert_eq!(retained.source_project, changed.source_project);
|
|
assert_eq!(retained.etag(), changed.etag());
|
|
assert_eq!(std::fs::read(&disk).unwrap(), accepted_bytes);
|
|
server.stop().await;
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let reopened = server.get(resource).await;
|
|
assert_eq!(reopened.model, changed.model);
|
|
assert_eq!(reopened.source_project, changed.source_project);
|
|
assert_eq!(reopened.etag(), changed.etag());
|
|
assert_pair(&reopened);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn source6_http_explicit_communication_ports_preview_apply_and_restart_preserve_decisions() {
|
|
let f = fixture();
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let original = import(&server, &f.bundle).await;
|
|
let resource = original.resource_id;
|
|
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![f.mean]);
|
|
request.mappings =
|
|
f.oa.into_iter()
|
|
.zip(f.sa)
|
|
.map(|(source, target)| TransitionMapping {
|
|
source,
|
|
target: Some(target),
|
|
target_kind: None,
|
|
})
|
|
.collect();
|
|
let ports = [Id::new_v4(), Id::new_v4()];
|
|
let create = |id, alias: &str| CommunicationPortChoice::Create {
|
|
id,
|
|
alias: alias.into(),
|
|
name: alias.into(),
|
|
description: "Explicit HTTP endpoint choice".into(),
|
|
kind: ComponentPortKind::Standard,
|
|
orientation: ComponentPortOrientation::Unset,
|
|
};
|
|
request
|
|
.communication_endpoints
|
|
.push(CommunicationEndpointDecision {
|
|
source: f.mean,
|
|
source_port: create(ports[0], "http_radio_source"),
|
|
target_port: create(ports[1], "http_radio_target"),
|
|
});
|
|
let accepted_bytes = std::fs::read(directory.path().join(format!("{resource}.json"))).unwrap();
|
|
let response = server
|
|
.client
|
|
.post(server.url(&format!("/projects/{resource}/transitions/preview")))
|
|
.header("if-match", original.etag())
|
|
.json(&request)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let preview: Value = response.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::OK, "{preview}");
|
|
assert!(
|
|
preview["plan"]["conflicts"].as_array().unwrap().is_empty(),
|
|
"{preview}"
|
|
);
|
|
assert_eq!(
|
|
preview["plan"]["request"]["communication_endpoints"],
|
|
serde_json::to_value(&request.communication_endpoints).unwrap()
|
|
);
|
|
assert_eq!(
|
|
server.get(resource).await.source_project,
|
|
original.source_project
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(directory.path().join(format!("{resource}.json"))).unwrap(),
|
|
accepted_bytes
|
|
);
|
|
let response = server
|
|
.client
|
|
.post(server.url(&format!("/projects/{resource}/transitions/apply")))
|
|
.header("if-match", original.etag())
|
|
.json(&preview)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let status = response.status();
|
|
let result: Value = response.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::OK, "{result}");
|
|
let changed = server.get(resource).await;
|
|
assert_eq!(changed.revision, original.revision + 1);
|
|
assert_pair(&changed);
|
|
let target = changed
|
|
.model
|
|
.traces
|
|
.values()
|
|
.find(|t| t.oa == f.mean)
|
|
.unwrap()
|
|
.sa;
|
|
assert!(
|
|
matches!(changed.model.relations[&target].body, RelationBody::BoundaryExchange { source, target, oriented:false, .. } if [source,target] == ports)
|
|
);
|
|
for (port, owner) in ports.into_iter().zip(f.sa) {
|
|
assert_eq!(changed.model.elements[&port].parent, Some(owner));
|
|
}
|
|
assert_eq!(
|
|
changed.model.relations[&f.mean],
|
|
original.model.relations[&f.mean]
|
|
);
|
|
assert_eq!(
|
|
changed.model.elements[&f.category],
|
|
original.model.elements[&f.category]
|
|
);
|
|
assert_eq!(
|
|
changed.model.diagrams[&f.diagram],
|
|
original.model.diagrams[&f.diagram]
|
|
);
|
|
assert_eq!(
|
|
changed.source_project.files["notes.arcadia"],
|
|
original.source_project.files["notes.arcadia"]
|
|
);
|
|
server.stop().await;
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let reopened = server.get(resource).await;
|
|
assert_eq!(reopened.model, changed.model);
|
|
assert_eq!(reopened.source_project, changed.source_project);
|
|
assert_eq!(reopened.etag(), changed.etag());
|
|
assert_pair(&reopened);
|
|
server.stop().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mixed_traceability_projection_roundtrip_preserves_dormant_http_state() {
|
|
let f = fixture();
|
|
let mut model = compile_bundle(&f.bundle, ProjectLimits::default())
|
|
.unwrap()
|
|
.decoded
|
|
.project;
|
|
let view = model.diagrams.get_mut(&f.diagram).unwrap();
|
|
view.layer = Layer::Sa;
|
|
view.projection = ProjectionKind::Traceability;
|
|
// The OA placements and category are intentionally opposite the view's layer.
|
|
view.view_options.hidden_edges = vec![f.mean];
|
|
for (index, element) in f.sa.into_iter().enumerate() {
|
|
model.place(
|
|
f.diagram,
|
|
element,
|
|
Position {
|
|
x: index as f32 * 400.,
|
|
y: 300.,
|
|
},
|
|
);
|
|
}
|
|
let mut bundle = f.bundle.clone();
|
|
bundle.files.insert(
|
|
bundle.entry.clone(),
|
|
format!(
|
|
"// Authored source-6 engineering rationale\n{}",
|
|
oa_core::language::format(&model).replacen(
|
|
"{\n",
|
|
"{\n include \"notes.arcadia\";\n",
|
|
1
|
|
)
|
|
),
|
|
);
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let original = import(&server, &bundle).await;
|
|
assert_pair(&original);
|
|
let resource = original.resource_id;
|
|
let mixed = oa_core::projection::project_view(&original.model, f.diagram);
|
|
assert!(
|
|
f.oa.iter()
|
|
.chain(&f.sa)
|
|
.all(|id| mixed.nodes.iter().any(|n| n.element == *id))
|
|
);
|
|
|
|
let mut ordinary = original.model.diagrams[&f.diagram].clone();
|
|
ordinary.projection = ProjectionKind::Allocation;
|
|
let response = transact(
|
|
&server,
|
|
&original,
|
|
vec![Command::EditDiagram(f.diagram, ordinary.clone())],
|
|
)
|
|
.await;
|
|
let status = response.status();
|
|
let etag = response
|
|
.headers()
|
|
.get("etag")
|
|
.map(|v| v.to_str().unwrap().to_owned());
|
|
let result: Value = response.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::OK, "{result}");
|
|
assert_eq!(result["changed"], true);
|
|
assert_eq!(result["dry_run"], false);
|
|
assert_eq!(result["revision"], original.revision + 1);
|
|
let changed = server.get(resource).await;
|
|
assert_eq!(etag.as_deref(), Some(changed.etag().as_str()));
|
|
let mut expected = original.model.clone();
|
|
expected.diagrams.insert(f.diagram, ordinary.clone());
|
|
assert_eq!(
|
|
changed.model, expected,
|
|
"only the projection changes; dormant state is retained"
|
|
);
|
|
assert_pair(&changed);
|
|
assert_eq!(
|
|
changed.source_project.files["notes.arcadia"],
|
|
bundle.files["notes.arcadia"]
|
|
);
|
|
assert!(
|
|
changed.source_project.files[&bundle.entry]
|
|
.starts_with("// Authored source-6 engineering rationale\n")
|
|
);
|
|
let projected = oa_core::projection::project_view(&changed.model, f.diagram);
|
|
assert!(projected.nodes.iter().all(|n| !f.oa.contains(&n.element)));
|
|
assert!(
|
|
f.sa.iter()
|
|
.all(|id| projected.nodes.iter().any(|n| n.element == *id))
|
|
);
|
|
|
|
// A dormant option still must name an existing category. Rejection must not
|
|
// advance the ETag or rewrite either side of the persisted model/source pair.
|
|
let disk = directory.path().join(format!("{resource}.json"));
|
|
let accepted_bytes = std::fs::read(&disk).unwrap();
|
|
ordinary.view_options.participant_categories = CategorySelection::Any(vec![Id::new_v4()]);
|
|
let rejected = transact(
|
|
&server,
|
|
&changed,
|
|
vec![Command::EditDiagram(f.diagram, ordinary)],
|
|
)
|
|
.await;
|
|
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
|
let retained = server.get(resource).await;
|
|
assert_eq!(retained.etag(), changed.etag());
|
|
assert_eq!(
|
|
serde_json::to_value(&retained).unwrap(),
|
|
serde_json::to_value(&changed).unwrap()
|
|
);
|
|
assert_eq!(std::fs::read(&disk).unwrap(), accepted_bytes);
|
|
server.stop().await;
|
|
|
|
let server = TestServer::start(directory.path(), None).await;
|
|
let reopened = server.get(resource).await;
|
|
assert_eq!(
|
|
serde_json::to_value(&reopened).unwrap(),
|
|
serde_json::to_value(&changed).unwrap()
|
|
);
|
|
assert_pair(&reopened);
|
|
let response = transact(
|
|
&server,
|
|
&reopened,
|
|
vec![Command::EditDiagram(
|
|
f.diagram,
|
|
original.model.diagrams[&f.diagram].clone(),
|
|
)],
|
|
)
|
|
.await;
|
|
let status = response.status();
|
|
let result: Value = response.json().await.unwrap();
|
|
assert_eq!(status, StatusCode::OK, "{result}");
|
|
assert_eq!(result["changed"], true);
|
|
assert_eq!(result["revision"], changed.revision + 1);
|
|
let restored = server.get(resource).await;
|
|
assert_eq!(restored.model, original.model);
|
|
assert_pair(&restored);
|
|
assert_eq!(
|
|
restored.source_project.files["notes.arcadia"],
|
|
bundle.files["notes.arcadia"]
|
|
);
|
|
let projected = oa_core::projection::project_view(&restored.model, f.diagram);
|
|
assert!(
|
|
f.oa.iter()
|
|
.chain(&f.sa)
|
|
.all(|id| projected.nodes.iter().any(|n| n.element == *id))
|
|
);
|
|
assert!(!projected.edges.iter().any(|edge| edge.id == f.mean));
|
|
server.stop().await;
|
|
}
|