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 = 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 = 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; }