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 ", 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, ) -> 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; }