feat(server): expose typed semantic transition review
This commit is contained in:
@@ -0,0 +1,675 @@
|
||||
use super::*;
|
||||
use oa_core::{semantic::*, transitions::*, *};
|
||||
|
||||
fn fixture() -> Project {
|
||||
oa_core::persistence::decode(include_bytes!(
|
||||
"../../../oa-core/tests/fixtures/v4-reviewed-prose.json"
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
fn key(n: u128) -> Id {
|
||||
Id::from_u128(0x40000000_0000_4000_8000_000000000000 + n)
|
||||
}
|
||||
fn request(
|
||||
source: Id,
|
||||
target: Id,
|
||||
decisions: Vec<(FacetSelector, SemanticDecision)>,
|
||||
) -> SemanticTransitionRequest {
|
||||
let direction = if source == key(3) {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
};
|
||||
let mut transition = TransitionRequest::new(direction, vec![source]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source,
|
||||
resolutions: decisions
|
||||
.into_iter()
|
||||
.map(|(facet, decision)| FacetResolution { facet, decision })
|
||||
.collect(),
|
||||
reset: vec![],
|
||||
});
|
||||
request
|
||||
}
|
||||
async fn post(
|
||||
server: &TestServer,
|
||||
id: Id,
|
||||
operation: &str,
|
||||
etag: Option<&str>,
|
||||
value: &impl serde::Serialize,
|
||||
) -> reqwest::Response {
|
||||
let mut call = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{id}/semantic-transitions/{operation}")))
|
||||
.json(value);
|
||||
if let Some(etag) = etag {
|
||||
call = call.header("if-match", etag);
|
||||
}
|
||||
call.send().await.unwrap()
|
||||
}
|
||||
async fn ok(response: reqwest::Response) -> Value {
|
||||
let status = response.status();
|
||||
let value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{value}");
|
||||
value
|
||||
}
|
||||
async fn preview(
|
||||
server: &TestServer,
|
||||
stored: &StoredProject,
|
||||
request: &SemanticTransitionRequest,
|
||||
) -> Value {
|
||||
ok(post(
|
||||
server,
|
||||
stored.resource_id,
|
||||
"preview",
|
||||
Some(&stored.etag()),
|
||||
request,
|
||||
)
|
||||
.await)
|
||||
.await
|
||||
}
|
||||
fn assert_pair(stored: &StoredProject) {
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&stored.source_project, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
stored.model
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_partial_review_query_source_and_restart_share_one_revision() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (id, _) = server.import(&fixture()).await;
|
||||
let mut stored = server.get(id).await;
|
||||
let mut bundle = stored.source_project.clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.insert_str(0, "// Authored review rationale\n");
|
||||
let changed = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/source-project")))
|
||||
.header("if-match", stored.etag())
|
||||
.json(&bundle)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(changed.status(), StatusCode::OK);
|
||||
stored = server.get(id).await;
|
||||
let disk_path = directory.path().join(format!("{id}.json"));
|
||||
let before_bytes = std::fs::read(&disk_path).unwrap();
|
||||
let query = json!({"traces":[key(4)],"references":{},"revision":stored.revision});
|
||||
let queried = ok(post(&server, id, "query", None, &query).await).await;
|
||||
assert_eq!(queried["items"][0]["drift"]["own"], "Unreviewed");
|
||||
let req = request(
|
||||
key(2),
|
||||
key(3),
|
||||
vec![
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::KeepTarget,
|
||||
),
|
||||
(
|
||||
FacetSelector::Header(HeaderFacet::Name),
|
||||
SemanticDecision::TakeSource,
|
||||
),
|
||||
],
|
||||
);
|
||||
let plan = preview(&server, &stored, &req).await;
|
||||
assert_eq!(plan["plan"]["conflicts"], json!([]));
|
||||
assert_eq!(
|
||||
plan["plan"]["reviews"][0]["acknowledged"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(std::fs::read(&disk_path).unwrap(), before_bytes);
|
||||
assert_eq!(server.get(id).await.etag(), stored.etag());
|
||||
let applied = ok(post(&server, id, "apply", Some(&stored.etag()), &plan).await).await;
|
||||
assert_eq!(applied["revision"], stored.revision + 1);
|
||||
let committed = server.get(id).await;
|
||||
assert_pair(&committed);
|
||||
assert!(
|
||||
committed.source_project.files[&committed.source_project.entry]
|
||||
.contains("// Authored review rationale")
|
||||
);
|
||||
assert_eq!(committed.model.elements[&key(3)].name, "OA dimension now");
|
||||
assert!(
|
||||
matches!(committed.model.elements[&key(3)].shared(), Some(SharedBody::DataType { unit, definition: DataTypeDefinition::Real { .. }, .. }) if unit == "ft")
|
||||
);
|
||||
let queried = ok(post(&server, id, "query", None, &json!({"traces":[key(4)]})).await).await;
|
||||
let facts: Vec<FacetDrift> =
|
||||
serde_json::from_value(queried["items"][0]["facets"].clone()).unwrap();
|
||||
assert_eq!(
|
||||
facts
|
||||
.iter()
|
||||
.find(|f| f.selector == FacetSelector::Data(DataFacet::Unit))
|
||||
.unwrap()
|
||||
.drift,
|
||||
Drift::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
facts
|
||||
.iter()
|
||||
.find(|f| f.selector == FacetSelector::Data(DataFacet::Constructor))
|
||||
.unwrap()
|
||||
.drift,
|
||||
Drift::Unreviewed
|
||||
);
|
||||
assert!(
|
||||
queried["items"][0]["drift"]["own_unreviewed"]
|
||||
.as_bool()
|
||||
.unwrap()
|
||||
);
|
||||
let repeat = preview(&server, &committed, &req).await;
|
||||
let reapplied = ok(post(&server, id, "apply", Some(&committed.etag()), &repeat).await).await;
|
||||
assert_eq!(reapplied["changed"], false);
|
||||
assert_eq!(reapplied["revision"], committed.revision);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(id).await;
|
||||
assert_eq!(reopened.model, committed.model);
|
||||
assert_eq!(reopened.source_project, committed.source_project);
|
||||
assert_eq!(reopened.etag(), committed.etag());
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_strict_rejection_stale_plans_and_source_only_changes_never_write() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (id, etag) = server.import(&fixture()).await;
|
||||
let stored = server.get(id).await;
|
||||
let disk_path = directory.path().join(format!("{id}.json"));
|
||||
let before = std::fs::read(&disk_path).unwrap();
|
||||
let req = request(
|
||||
key(2),
|
||||
key(3),
|
||||
vec![(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::TakeSource,
|
||||
)],
|
||||
);
|
||||
let plan = preview(&server, &stored, &req).await;
|
||||
for operation in ["preview", "apply"] {
|
||||
let body = if operation == "preview" {
|
||||
serde_json::to_value(&req).unwrap()
|
||||
} else {
|
||||
plan.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
post(&server, id, operation, None, &body).await.status(),
|
||||
StatusCode::PRECONDITION_REQUIRED
|
||||
);
|
||||
}
|
||||
let mut malformed = serde_json::to_value(&req).unwrap();
|
||||
malformed["reviews"][0]["resolutions"][0]["facet"] = json!({"Data":"arbitrary_path"});
|
||||
assert_eq!(
|
||||
post(&server, id, "preview", Some(&etag), &malformed)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
malformed = serde_json::to_value(&req).unwrap();
|
||||
malformed["unexpected"] = true.into();
|
||||
assert_eq!(
|
||||
post(&server, id, "preview", Some(&etag), &malformed)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
for (query, expected) in [
|
||||
(json!({"traces":[]}), StatusCode::BAD_REQUEST),
|
||||
(json!({"traces":[key(4),key(4)]}), StatusCode::BAD_REQUEST),
|
||||
(json!({"traces":[key(99)]}), StatusCode::NOT_FOUND),
|
||||
(
|
||||
json!({"traces":[key(4)],"revision":99}),
|
||||
StatusCode::CONFLICT,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
post(&server, id, "query", None, &query).await.status(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
for mutation in [
|
||||
"commands",
|
||||
"base_fingerprint",
|
||||
"prepared_fingerprint",
|
||||
"base_revision",
|
||||
] {
|
||||
let mut rejected = plan.clone();
|
||||
let expected = if mutation == "base_revision" {
|
||||
rejected[mutation] = 99.into();
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
} else {
|
||||
rejected["plan"][mutation] = if mutation == "commands" {
|
||||
json!([])
|
||||
} else {
|
||||
json!("altered")
|
||||
};
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
};
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(&etag), &rejected)
|
||||
.await
|
||||
.status(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let mut duplicate = req.clone();
|
||||
let repeated = duplicate.reviews[0].resolutions[0].clone();
|
||||
duplicate.reviews[0].resolutions.push(repeated);
|
||||
let conflicted = preview(&server, &stored, &duplicate).await;
|
||||
assert!(
|
||||
!conflicted["plan"]["conflicts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(&etag), &conflicted)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert_eq!(std::fs::read(&disk_path).unwrap(), before);
|
||||
assert_eq!(server.get(id).await.etag(), etag);
|
||||
let mut bundle = stored.source_project.clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.push_str("\n// Source-only concurrent edit\n");
|
||||
let changed = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{id}/source-project")))
|
||||
.header("if-match", &etag)
|
||||
.json(&bundle)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(changed.status(), StatusCode::OK);
|
||||
let current = server.get(id).await;
|
||||
assert_eq!(current.model, stored.model);
|
||||
let bytes = std::fs::read(&disk_path).unwrap();
|
||||
for precondition in [&etag, ¤t.etag()] {
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(precondition), &plan)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
}
|
||||
assert_eq!(std::fs::read(&disk_path).unwrap(), bytes);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_candidate_constructor_reverse_direction_and_explicit_trace_selection() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let mut model = fixture();
|
||||
let mut second = model.traces[&key(4)].clone();
|
||||
second.id = key(5);
|
||||
second.alias = "second_review".into();
|
||||
second.kind = TraceKind::Manual;
|
||||
model.traces.insert(second.id, second);
|
||||
let (id, _) = server.import(&model).await;
|
||||
let stored = server.get(id).await;
|
||||
let mut req = request(
|
||||
key(3),
|
||||
key(2),
|
||||
vec![
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
SemanticDecision::TakeSource,
|
||||
),
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::Candidate,
|
||||
),
|
||||
],
|
||||
);
|
||||
let ambiguous = preview(&server, &stored, &req).await;
|
||||
assert!(
|
||||
!ambiguous["plan"]["conflicts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
req.traces.insert(key(3), key(5));
|
||||
let mut candidate = model.elements[&key(2)].clone();
|
||||
*candidate.shared_mut().unwrap() = model.elements[&key(3)].shared().unwrap().clone();
|
||||
if let SharedBody::DataType { unit, .. } = candidate.shared_mut().unwrap() {
|
||||
*unit = "cm".into();
|
||||
}
|
||||
req.candidates
|
||||
.push(SemanticRecord::Element(candidate.clone()));
|
||||
req.references.insert(key(900), key(901)); // Historical correspondence survives transport unchanged.
|
||||
let envelope = preview(&server, &stored, &req).await;
|
||||
assert_eq!(
|
||||
envelope["plan"]["request"],
|
||||
serde_json::to_value(&req).unwrap()
|
||||
);
|
||||
assert_eq!(envelope["plan"]["conflicts"], json!([]));
|
||||
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
|
||||
let applied = server.get(id).await;
|
||||
assert_eq!(applied.model.elements[&key(2)], candidate);
|
||||
assert!(
|
||||
applied.model.traces[&key(4)]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
applied.model.traces[&key(5)]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.is_some()
|
||||
);
|
||||
assert_pair(&applied);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_new_mapping_keeps_preview_generated_identifiers() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let mut model = Project::empty();
|
||||
let source = model
|
||||
.add_element(Kind::Activity, Layer::Oa, "Inspect", None)
|
||||
.unwrap();
|
||||
let (id, _) = server.import(&model).await;
|
||||
let stored = server.get(id).await;
|
||||
let req = SemanticTransitionRequest::new(TransitionRequest::new(
|
||||
TransitionDirection::OaToSa,
|
||||
vec![source],
|
||||
));
|
||||
let envelope = preview(&server, &stored, &req).await;
|
||||
assert_eq!(envelope["plan"]["conflicts"], json!([]));
|
||||
let plan: SemanticTransitionPlan = serde_json::from_value(envelope["plan"].clone()).unwrap();
|
||||
let expected = oa_core::semantic::apply_semantic_transition(&model, &plan).unwrap();
|
||||
ok(post(&server, id, "apply", Some(&stored.etag()), &envelope).await).await;
|
||||
assert_eq!(server.get(id).await.model, expected);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_resets_and_invalid_candidates_preserve_deliberate_review_boundaries() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let model = fixture();
|
||||
let reviewed = oa_core::semantic::plan_semantic_transition(
|
||||
&model,
|
||||
request(
|
||||
key(2),
|
||||
key(3),
|
||||
vec![
|
||||
(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::KeepTarget,
|
||||
),
|
||||
(
|
||||
FacetSelector::Header(HeaderFacet::Name),
|
||||
SemanticDecision::KeepTarget,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let model = oa_core::semantic::apply_semantic_transition(&model, &reviewed).unwrap();
|
||||
let (id, _) = server.import(&model).await;
|
||||
let stored = server.get(id).await;
|
||||
let before = std::fs::read(directory.path().join(format!("{id}.json"))).unwrap();
|
||||
let mut req = request(
|
||||
key(2),
|
||||
key(3),
|
||||
vec![(
|
||||
FacetSelector::Data(DataFacet::Unit),
|
||||
SemanticDecision::Candidate,
|
||||
)],
|
||||
);
|
||||
let mut candidate = model.elements[&key(3)].clone();
|
||||
candidate.name = "Unselected incidental edit".into();
|
||||
req.candidates.push(SemanticRecord::Element(candidate));
|
||||
let invalid = preview(&server, &stored, &req).await;
|
||||
assert!(!invalid["plan"]["conflicts"].as_array().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(&stored.etag()), &invalid)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(directory.path().join(format!("{id}.json"))).unwrap(),
|
||||
before
|
||||
);
|
||||
req = request(key(2), key(3), vec![]);
|
||||
req.reviews[0]
|
||||
.reset
|
||||
.push(FacetSelector::Data(DataFacet::Unit));
|
||||
let reset = preview(&server, &stored, &req).await;
|
||||
assert_eq!(
|
||||
reset["plan"]["request"]["reviews"][0]["reset"],
|
||||
json!([{"Data":"Unit"}])
|
||||
);
|
||||
assert_eq!(reset["plan"]["conflicts"], json!([]));
|
||||
ok(post(&server, id, "apply", Some(&stored.etag()), &reset).await).await;
|
||||
let current = server.get(id).await;
|
||||
let facts = ¤t.model.traces[&key(4)]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets;
|
||||
assert_eq!(facts.len(), 1);
|
||||
assert_eq!(
|
||||
facts[0].selector(),
|
||||
FacetSelector::Header(HeaderFacet::Name)
|
||||
);
|
||||
assert_eq!(current.model.elements, model.elements);
|
||||
assert_pair(¤t);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
fn cleanup_fixture() -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Source record", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Target record", None)
|
||||
.unwrap();
|
||||
let base = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Base record", None)
|
||||
.unwrap();
|
||||
for (id, supertype) in [(oa, None), (base, None), (sa, Some(base))] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let leaf = p
|
||||
.add_element(Kind::DataField, Layer::Sa, "Coordinate", Some(base))
|
||||
.unwrap();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Sa, "Location", None)
|
||||
.unwrap();
|
||||
let field = p
|
||||
.add_element(Kind::DataField, Layer::Sa, "Payload", Some(item))
|
||||
.unwrap();
|
||||
for (id, type_ref) in [(leaf, TypeRef::Real), (field, TypeRef::Named(sa))] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref,
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
let sender = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let receiver = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Location report",
|
||||
sender,
|
||||
receiver,
|
||||
);
|
||||
let RelationBody::FunctionalExchange {
|
||||
items,
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = &mut p.relations.get_mut(&exchange).unwrap().body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
items.push(item);
|
||||
for id in [*source_port, *target_port] {
|
||||
match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() {
|
||||
SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
..
|
||||
} => incoming_items.push(item),
|
||||
SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput { outgoing_items },
|
||||
..
|
||||
} => outgoing_items.push(item),
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Sa, "Report location", None)
|
||||
.unwrap();
|
||||
let message = p
|
||||
.add_element(Kind::Occurrence, Layer::Sa, "Report", Some(scenario))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![MessageValue {
|
||||
path: vec![field],
|
||||
field: leaf,
|
||||
expression: "10".into(),
|
||||
}],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(message);
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "record_trace".into(),
|
||||
name: "Record correspondence".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
(p, oa, sa, message)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_cleanup_impacts_require_exact_acceptance_before_atomic_apply() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (model, oa, sa, message) = cleanup_fixture();
|
||||
let (id, _) = server.import(&model).await;
|
||||
let stored = server.get(id).await;
|
||||
let path = directory.path().join(format!("{id}.json"));
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let mut req = request(
|
||||
oa,
|
||||
sa,
|
||||
vec![(
|
||||
FacetSelector::Data(DataFacet::RecordSupertype),
|
||||
SemanticDecision::TakeSource,
|
||||
)],
|
||||
);
|
||||
let unresolved = preview(&server, &stored, &req).await;
|
||||
assert!(
|
||||
!unresolved["plan"]["conflicts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let impacts: Vec<SemanticImpact> =
|
||||
serde_json::from_value(unresolved["plan"]["impacts"].clone()).unwrap();
|
||||
assert_eq!(impacts.len(), 1);
|
||||
assert_eq!(impacts[0].object, message);
|
||||
assert_ne!(impacts[0].before, impacts[0].after);
|
||||
assert_eq!(
|
||||
post(&server, id, "apply", Some(&stored.etag()), &unresolved)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
let mut inaccurate = impacts.clone();
|
||||
inaccurate[0].after = inaccurate[0].before.clone();
|
||||
req.accepted_impacts = inaccurate;
|
||||
let inaccurate = preview(&server, &stored, &req).await;
|
||||
assert!(
|
||||
!inaccurate["plan"]["conflicts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
req.accepted_impacts = impacts;
|
||||
let approved = preview(&server, &stored, &req).await;
|
||||
assert_eq!(approved["plan"]["conflicts"], json!([]));
|
||||
assert_eq!(
|
||||
approved["plan"]["request"]["accepted_impacts"],
|
||||
serde_json::to_value(&req.accepted_impacts).unwrap()
|
||||
);
|
||||
ok(post(&server, id, "apply", Some(&stored.etag()), &approved).await).await;
|
||||
let current = server.get(id).await;
|
||||
assert_eq!(current.revision, stored.revision + 1);
|
||||
assert!(
|
||||
matches!(current.model.elements[&message].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.is_empty())
|
||||
);
|
||||
assert_pair(¤t);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
assert_eq!(server.get(id).await.model, current.model);
|
||||
server.stop().await;
|
||||
}
|
||||
Reference in New Issue
Block a user