Files
ARCADIA-Studio/crates/oa-server/tests/http/semantic_transitions.rs
T

676 lines
22 KiB
Rust

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, &current.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 = &current.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(&current);
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(&current);
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
assert_eq!(server.get(id).await.model, current.model);
server.stop().await;
}