From ecc49e0417219549b12c0063a14b81afcb820a42 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Sat, 12 Sep 2026 19:27:49 -0700 Subject: [PATCH] feat(core): prepare typed Scenario layout and source lifecycle --- .../oa-core/examples/release_dense_views.rs | 2 +- crates/oa-core/src/demo.rs | 1 + crates/oa-core/src/language.rs | 2 +- crates/oa-core/src/lib.rs | 4 + crates/oa-core/src/model.rs | 27 + crates/oa-core/src/projection.rs | 6 + crates/oa-core/src/references.rs | 107 ++- .../src/references/scenario_layout_tests.rs | 388 +++++++++ crates/oa-core/src/scenario_layout.rs | 429 ++++++++++ crates/oa-core/src/scenario_layout/tests.rs | 787 ++++++++++++++++++ .../scenario_layout/tests/source_lifecycle.rs | 245 ++++++ crates/oa-core/src/session.rs | 44 +- crates/oa-core/src/source_project.rs | 116 ++- crates/oa-core/src/validation.rs | 1 + crates/oa-core/tests/release_routing.rs | 1 + crates/oa-core/tests/source7_contract.rs | 2 +- crates/oa-core/tests/state_diagrams.rs | 1 + crates/oa-desktop/src/app.rs | 249 +++++- .../src/app/architecture_authoring.rs | 26 +- .../src/app/diagram_reveal/tests.rs | 1 + crates/oa-desktop/src/app/diagram_usages.rs | 8 + .../oa-desktop/src/behavior/state_canvas.rs | 1 + crates/oa-desktop/src/behavior/state_edit.rs | 1 + crates/oa-desktop/src/forms.rs | 4 +- crates/oa-desktop/src/port_workflow.rs | 1 + crates/oa-desktop/src/views.rs | 15 +- crates/oa-desktop/src/workflow.rs | 37 +- .../src/workflow/transition_tests.rs | 1 + examples/release1/model.rs | 2 +- harness/basic-editing-contract.rs | 1 + harness/behavior-creation-contract.rs | 2 + harness/child-work-native-contract.rs | 2 +- .../README.md | 39 + .../generate-communication-means.rs | 1 + harness/staged-source-authoring-contract.rs | 2 + 35 files changed, 2499 insertions(+), 57 deletions(-) create mode 100644 crates/oa-core/src/references/scenario_layout_tests.rs create mode 100644 crates/oa-core/src/scenario_layout.rs create mode 100644 crates/oa-core/src/scenario_layout/tests.rs create mode 100644 crates/oa-core/src/scenario_layout/tests/source_lifecycle.rs create mode 100644 harness/experiments/scenario-movement-workflows-20260913/README.md diff --git a/crates/oa-core/examples/release_dense_views.rs b/crates/oa-core/examples/release_dense_views.rs index acc62bac..8aeeb370 100644 --- a/crates/oa-core/examples/release_dense_views.rs +++ b/crates/oa-core/examples/release_dense_views.rs @@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec, Vec, Vec) { ) }) .collect(); - p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] }); + p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] }); (p, diagram, exchanges, categories, contexts) } diff --git a/crates/oa-core/src/demo.rs b/crates/oa-core/src/demo.rs index 93cb45e9..b7812acb 100644 --- a/crates/oa-core/src/demo.rs +++ b/crates/oa-core/src/demo.rs @@ -81,6 +81,7 @@ fn diagram( projection, view_options: Default::default(), nodes: BTreeMap::new(), + layout: None, routes: vec![], }, ); diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 24e3f891..cafeb5e6 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -1823,7 +1823,7 @@ pub(crate) fn resolve_declarations( return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract."))); } let defaults = - serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()}); + serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default(),"layout":null}); let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields) .map_err(&conversion_error)?; header(&mut record, d, true); diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index 7197328c..340c0c59 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod ports; pub mod projection; pub mod references; pub mod routing; +pub mod scenario_layout; pub mod semantic; pub mod session; pub mod source_files; @@ -18,6 +19,9 @@ pub mod validation; pub mod view_options; pub use model::*; pub use ports::*; +pub use scenario_layout::{ + DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition, +}; pub use semantic::*; pub use session::*; mod demo; diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs index 041639a6..a81221a7 100644 --- a/crates/oa-core/src/model.rs +++ b/crates/oa-core/src/model.rs @@ -1436,6 +1436,7 @@ pub enum ProjectionKind { FunctionalDataflow, Capabilities, StateMachine { machine: Id }, + Scenario { scenario: Id }, } impl ProjectionKind { pub fn state_machine(self) -> Option { @@ -1445,8 +1446,30 @@ impl ProjectionKind { } } + /// The semantic owner of a saved Scenario representation. + pub fn scenario(self) -> Option { + match self { + Self::Scenario { scenario } => Some(scenario), + _ => None, + } + } + + /// Explicitly classify architecture views; absence of a state machine is insufficient. + pub fn is_architecture(self) -> bool { + match self { + Self::Overview + | Self::Decomposition + | Self::Allocation + | Self::Traceability + | Self::FunctionalDataflow + | Self::Capabilities => true, + Self::StateMachine { .. } | Self::Scenario { .. } => false, + } + } + pub fn includes(self, kind: Kind) -> bool { match self { + Self::Scenario { .. } => false, Self::StateMachine { .. } => { matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state() } @@ -1471,6 +1494,7 @@ impl std::fmt::Display for ProjectionKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::StateMachine { .. } => "State machine", + Self::Scenario { .. } => "Scenario", Self::Allocation => "Architecture · allocated work", Self::FunctionalDataflow => "Functional dataflow", Self::Capabilities => "Missions & capabilities", @@ -1495,6 +1519,8 @@ pub struct Diagram { pub nodes: BTreeMap, #[serde(default)] pub routes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layout: Option, } /// Authored presentation for one existing relation or trace in one diagram. /// Computed endpoints and automatic routing elbows are never persisted here. @@ -1921,6 +1947,7 @@ impl Project { view_options: crate::view_options::ViewOptions::default(), nodes: BTreeMap::new(), routes: vec![], + layout: None, }, ); id diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs index befb027b..ce4b2930 100644 --- a/crates/oa-core/src/projection.rs +++ b/crates/oa-core/src/projection.rs @@ -278,6 +278,11 @@ fn project_diagram_impl( mut availability: Option<&mut BTreeMap>, index: Option<&crate::view_options::ViewIndex>, ) -> ProjectedView { + // Saved Scenario layout is not an architecture/state projection. Its dedicated + // renderer is not wired yet; never interpret its payload through this fallback. + if d.projection.scenario().is_some() { + return ProjectedView::default(); + } let parents = parents(p, d); let mut nodes = Vec::new(); for n in d.nodes.values() { @@ -394,6 +399,7 @@ fn project_diagram_impl( continue; } let included = match d.projection { + ProjectionKind::Scenario { .. } => false, ProjectionKind::StateMachine { machine } => matches!(&r.body, RelationBody::StateTransition { transition_kind, .. } if *transition_kind != TransitionKind::Internal diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index 470337f6..05eca61d 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -203,7 +203,81 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) { _ => {} } } -pub fn remap_references(p: &mut Project, old: Id, new: Id) { +/// Every semantic/presentation reference in a diagram, excluding its own identities. +pub fn diagram_references(d: &Diagram) -> Vec { + let mut copy = d.clone(); + let mut refs = Vec::new(); + visit_diagram_refs(&mut copy, |id| refs.push(*id)); + refs +} + +pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) { + d.view_options.visit_references(&mut visit); + match &mut d.projection { + ProjectionKind::StateMachine { machine } => visit(machine), + ProjectionKind::Scenario { scenario } => visit(scenario), + _ => {} + } + if let Some(layout) = &mut d.layout { + layout.visit_references(&mut visit); + } + for route in &mut d.routes { + visit(&mut route.edge); + } + for node in d.nodes.values_mut() { + for anchor in &mut node.port_anchors { + visit(&mut anchor.port); + } + visit(&mut node.element); + } +} + +/// Preflight reference-key collisions before the caller changes object identities. +/// Fresh targets are allowed. This is not whole-project admission: unrelated +/// invalid records remain repairable within a transaction. Future typed reference +/// domains must compose their own preflight here before any remap mutation. +pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> { + if old == new { + return Ok(()); + } + let remap = |id: Id| if id == old { new } else { id }; + for diagram in p.diagrams.values() { + let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else { + continue; + }; + let mut lanes = std::collections::BTreeMap::new(); + for lane in &layout.lanes { + let affected = lane.lifeline == old; + if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected) + && (affected || previous_affected) + { + return Err(ModelError( + "Identity remap would collide with a Scenario lane position.".into(), + )); + } + } + let mut messages = std::collections::BTreeMap::new(); + for message in &layout.messages { + let mut address = message.address.clone(); + let affected = address.occurrence == old || address.references.contains(&old); + address.occurrence = remap(address.occurrence); + for reference in &mut address.references { + *reference = remap(*reference); + } + if let Some(previous_affected) = messages.insert(address, affected) + && (affected || previous_affected) + { + return Err(ModelError( + "Identity remap would collide with a Scenario message position.".into(), + )); + } + } + } + Ok(()) +} + +pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> { + check_remap(p, old, new)?; for e in p.elements.values_mut() { visit_element_refs(e, |id| { if *id == old { @@ -219,31 +293,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) { }); } for d in p.diagrams.values_mut() { - d.view_options.visit_references(|id| { + visit_diagram_refs(d, |id| { if *id == old { *id = new; } }); - if let ProjectionKind::StateMachine { machine } = &mut d.projection - && *machine == old - { - *machine = new; - } - for route in &mut d.routes { - if route.edge == old { - route.edge = new; - } - } - for n in d.nodes.values_mut() { - for anchor in &mut n.port_anchors { - if anchor.port == old { - anchor.port = new; - } - } - if n.element == old { - n.element = new; - } - } } for a in p.artifact_links.values_mut() { if a.owner == old { @@ -265,6 +319,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) { }); } } + Ok(()) } /// IDs removed by a cascade. Cross-layer traced counterparts are never included. pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet { @@ -339,7 +394,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool) for d in p.diagrams.values() { if d.projection .state_machine() - .is_some_and(|machine| removed.contains(&machine)) + .or_else(|| d.projection.scenario()) + .is_some_and(|owner| removed.contains(&owner)) { removed.insert(d.id); } @@ -412,6 +468,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet) { p.artifact_links.retain(|id, _| !removed.contains(id)); for d in p.diagrams.values_mut() { d.view_options.retain_references(|id| !removed.contains(id)); + crate::scenario_layout::prune_deleted(d, &removed); d.nodes.retain(|id, _| !removed.contains(id)); d.routes.retain(|route| !removed.contains(&route.edge)); for node in d.nodes.values_mut() { @@ -589,3 +646,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet) { } } } + +#[cfg(test)] +#[path = "references/scenario_layout_tests.rs"] +mod scenario_layout_tests; diff --git a/crates/oa-core/src/references/scenario_layout_tests.rs b/crates/oa-core/src/references/scenario_layout_tests.rs new file mode 100644 index 00000000..efb1c86a --- /dev/null +++ b/crates/oa-core/src/references/scenario_layout_tests.rs @@ -0,0 +1,388 @@ +use super::*; +use std::collections::BTreeMap; + +struct Fixture { + project: Project, + owner: Id, + lanes: [Id; 2], + messages: [Id; 2], + views: [Id; 2], +} + +fn address(occurrence: Id) -> OccurrenceEndpoint { + OccurrenceEndpoint { + references: vec![], + occurrence, + } +} + +fn layout(project: &Project, view: Id) -> &ScenarioLayout { + let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else { + panic!("expected authored Scenario positions") + }; + layout +} + +fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout { + let Some(DiagramLayout::Scenario(layout)) = + &mut project.diagrams.get_mut(&view).unwrap().layout + else { + panic!("expected authored Scenario positions") + }; + layout +} + +fn admitted(project: &Project) { + let errors: Vec<_> = crate::validation::validate(project) + .into_iter() + .filter(|issue| issue.severity == crate::validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); +} + +fn fixture() -> Fixture { + let mut project = Project::empty(); + let owner = project + .add_element(Kind::Scenario, Layer::Oa, "Delivery", None) + .unwrap(); + let lanes = ["Sender", "Receiver"].map(|name| { + project + .add_element(Kind::Lifeline, Layer::Oa, name, Some(owner)) + .unwrap() + }); + let messages = ["First message", "Second message"].map(|name| { + let id = project + .add_element(Kind::Occurrence, Layer::Oa, name, Some(owner)) + .unwrap(); + *project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: None, + sender: Some(lanes[0]), + receiver: Some(lanes[1]), + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + precondition: String::new(), + postcondition: String::new(), + }; + id + }); + *project + .elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() = messages.to_vec(); + let views = ["delivery_view", "review_view"].map(|alias| { + let id = Id::new_v4(); + project.diagrams.insert( + id, + Diagram { + id, + alias: alias.into(), + name: alias.into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: BTreeMap::new(), + routes: vec![], + layout: Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: vec![ + ScenarioLanePosition { + lifeline: lanes[0], + x: 120., + }, + ScenarioLanePosition { + lifeline: lanes[1], + x: 420., + }, + ], + messages: vec![ + ScenarioMessagePosition { + address: address(messages[0]), + send_y: 220., + }, + ScenarioMessagePosition { + address: address(messages[1]), + send_y: 360., + }, + ], + })), + }, + ); + id + }); + admitted(&project); + Fixture { + project, + owner, + lanes, + messages, + views, + } +} + +#[test] +fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() { + let mut f = fixture(); + let before = f.project.clone(); + let temporary = Id::new_v4(); + for ids in [f.lanes, f.messages] { + f.project = crate::execute_batch( + &f.project, + &[ + Command::Reidentify(ids[0], temporary), + Command::Reidentify(ids[1], ids[0]), + Command::Reidentify(temporary, ids[1]), + ], + ) + .unwrap(); + } + admitted(&f.project); + assert_eq!( + f.project.elements[&f.owner].steps().unwrap(), + &vec![f.messages[1], f.messages[0]] + ); + for view in f.views { + let rows = layout(&f.project, view); + assert_eq!( + rows.lanes, + vec![ + ScenarioLanePosition { + lifeline: f.lanes[1], + x: 120. + }, + ScenarioLanePosition { + lifeline: f.lanes[0], + x: 420. + } + ] + ); + assert_eq!( + rows.messages, + vec![ + ScenarioMessagePosition { + address: address(f.messages[1]), + send_y: 220. + }, + ScenarioMessagePosition { + address: address(f.messages[0]), + send_y: 360. + } + ] + ); + } + // Reverse the swaps: no values or identities other than those requested drift. + for ids in [f.lanes, f.messages] { + f.project = crate::execute_batch( + &f.project, + &[ + Command::Reidentify(ids[0], temporary), + Command::Reidentify(ids[1], ids[0]), + Command::Reidentify(temporary, ids[1]), + ], + ) + .unwrap(); + } + assert_eq!(f.project, before); +} + +#[test] +fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() { + for message in [false, true] { + let mut f = fixture(); + let [old, new] = if message { f.messages } else { f.lanes }; + let before = f.project.clone(); + assert!(check_remap(&f.project, old, new).is_err()); + assert!(remap_references(&mut f.project, old, new).is_err()); + assert_eq!(f.project, before); + } +} + +#[test] +fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() { + let mut f = fixture(); + let fresh = Id::new_v4(); + // A repair candidate can already have a dangling coordinate key. It must not + // silently merge with the live lane, even though the destination ID is vacant. + layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh; + let before = f.project.clone(); + assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err()); + assert_eq!(f.project, before); +} + +#[test] +fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() { + let mut f = fixture(); + let [old, new] = [Id::new_v4(), Id::new_v4()]; + let rows = layout_mut(&mut f.project, f.views[0]); + // Future/invalid expanded candidates still participate in reference safety. + rows.messages[0].address.references = vec![old]; + rows.messages[1].address = OccurrenceEndpoint { + references: vec![new], + occurrence: f.messages[0], + }; + let refs = diagram_references(&f.project.diagrams[&f.views[0]]); + assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new)); + let before = f.project.clone(); + assert!(remap_references(&mut f.project, old, new).is_err()); + assert_eq!(f.project, before); + let fresh = Id::new_v4(); + remap_references(&mut f.project, old, fresh).unwrap(); + let rows = layout(&f.project, f.views[0]); + assert_eq!(rows.messages[0].address.references, vec![fresh]); + assert_eq!(rows.messages[0].address.occurrence, f.messages[0]); + assert_eq!(rows.messages[1].address.references, vec![new]); +} + +#[test] +fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() { + let mut f = fixture(); + let other_owner = f + .project + .add_element(Kind::Scenario, Layer::Oa, "Independent", None) + .unwrap(); + let mut other_view = f.project.diagrams[&f.views[0]].clone(); + other_view.id = Id::new_v4(); + other_view.alias = "independent_view".into(); + other_view.projection = ProjectionKind::Scenario { + scenario: other_owner, + }; + other_view.layout = None; + f.project.diagrams.insert(other_view.id, other_view.clone()); + admitted(&f.project); + let fresh_owner = Id::new_v4(); + f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner)) + .unwrap() + .project; + for view in f.views { + assert_eq!( + f.project.diagrams[&view].projection.scenario(), + Some(fresh_owner) + ); + } + let impact = deletion_impact(&f.project, fresh_owner); + assert!( + f.views + .iter() + .chain(&f.lanes) + .chain(&f.messages) + .all(|id| impact.contains(id)) + ); + assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id)); + delete_cascade(&mut f.project, fresh_owner); + assert_eq!(f.project.diagrams[&other_view.id], other_view); + assert!( + f.views + .iter() + .all(|id| !f.project.diagrams.contains_key(id)) + ); + admitted(&f.project); +} + +#[test] +fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() { + let mut f = fixture(); + let lane = f.project.elements[&f.lanes[0]].clone(); + let message = f.project.elements[&f.messages[0]].clone(); + delete_cascade(&mut f.project, lane.id); + for view in f.views { + let rows = layout(&f.project, view); + assert_eq!(rows.lanes.len(), 1); + assert_eq!(rows.lanes[0].lifeline, f.lanes[1]); + // Missing endpoint assignment does not erase a still-live message's spacing. + assert_eq!(rows.messages.len(), 2); + } + let mut pending = BTreeSet::new(); + delete_cascade_deferred(&mut f.project, message.id, &mut pending); + // Pruning happens during deletion, before later batch commands can recreate IDs. + assert!( + layout(&f.project, f.views[0]) + .messages + .iter() + .all(|row| row.address.occurrence != message.id) + ); + f.project.elements.insert(lane.id, lane); + f.project.elements.insert(message.id, message); + f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() + .insert(0, f.messages[0]); + finish_deletions(&mut f.project, &pending); + for view in f.views { + let rows = layout(&f.project, view); + assert_eq!(rows.lanes.len(), 1); + assert_eq!( + rows.messages, + vec![ScenarioMessagePosition { + address: address(f.messages[1]), + send_y: 360. + }] + ); + } + admitted(&f.project); +} + +#[test] +fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() { + let mut f = fixture(); + let unrelated = Id::new_v4(); + let rows = layout_mut(&mut f.project, f.views[1]); + let invalid = ScenarioLanePosition { + lifeline: unrelated, + x: MAX_COORDINATE * 2., + }; + rows.lanes.extend([invalid.clone(), invalid]); + let fresh = Id::new_v4(); + let untouched = f.project.diagrams[&f.views[1]].clone(); + let before = f.project.clone(); + remap_references(&mut f.project, f.owner, f.owner).unwrap(); + assert_eq!(f.project, before); + // The duplicate lane records do not involve the remapped message identity. + remap_references(&mut f.project, f.messages[0], fresh).unwrap(); + assert_eq!( + layout(&f.project, f.views[1]).lanes, + layout_from(&untouched).lanes + ); + assert_eq!( + layout(&f.project, f.views[1]).messages[0] + .address + .occurrence, + fresh + ); + assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.); +} + +fn layout_from(diagram: &Diagram) -> &ScenarioLayout { + let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else { + panic!("missing layout") + }; + layout +} + +#[test] +fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() { + let mut f = fixture(); + // Expanded addresses are not admitted in this slice, but deleting their + // explicit references must still make an intermediate repair candidate safe. + layout_mut(&mut f.project, f.views[0]).messages[0] + .address + .references = vec![f.lanes[0]]; + delete_cascade(&mut f.project, f.lanes[0]); + assert_eq!( + layout(&f.project, f.views[0]).messages, + vec![ScenarioMessagePosition { + address: address(f.messages[1]), + send_y: 360. + }] + ); + assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2); + assert!(f.project.elements.contains_key(&f.messages[0])); + admitted(&f.project); +} diff --git a/crates/oa-core/src/scenario_layout.rs b/crates/oa-core/src/scenario_layout.rs new file mode 100644 index 00000000..89ce02ae --- /dev/null +++ b/crates/oa-core/src/scenario_layout.rs @@ -0,0 +1,429 @@ +//! Authored Scenario presentation. Structural admission never changes event order. +use crate::{ + Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint, + Project, SharedBody, validation, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum DiagramLayout { + Scenario(ScenarioLayout), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct ScenarioLayout { + #[serde(default)] + pub lanes: Vec, + #[serde(default)] + pub messages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScenarioLanePosition { + pub lifeline: Id, + pub x: f32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScenarioMessagePosition { + pub address: OccurrenceEndpoint, + /// Actual send endpoint, not the renderer's receive-oriented lifecycle row. + pub send_y: f32, +} + +impl DiagramLayout { + /// Visit every explicit reference, including unsupported paths in a candidate. + /// The caller owns remap preflight and final atomic validation. + pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) { + match self { + Self::Scenario(layout) => { + for lane in &mut layout.lanes { + visit(&mut lane.lifeline); + } + for message in &mut layout.messages { + for reference in &mut message.address.references { + visit(reference); + } + visit(&mut message.address.occurrence); + } + } + } + } +} + +/// Proposed aggregate accounting contribution; the shared admission owner applies it. +/// One payload plus one record per lane/message. No graph objects are introduced. +pub fn record_count(diagram: &Diagram) -> usize { + match &diagram.layout { + None => 0, + Some(DiagramLayout::Scenario(layout)) => 1usize + .saturating_add(layout.lanes.len()) + .saturating_add(layout.messages.len()), + } +} + +fn coordinate(value: f32) -> bool { + value.is_finite() && value.abs() <= MAX_COORDINATE +} + +fn owner(p: &Project, diagram: &Diagram) -> Result { + let scenario = diagram + .projection + .scenario() + .ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?; + if !p + .elements + .get(&scenario) + .is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer) + { + return Err(ModelError( + "Scenario projection requires an existing Scenario in the diagram layer.".into(), + )); + } + Ok(scenario) +} + +fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool { + p.elements.get(&lifeline).is_some_and(|element| { + element.kind() == Kind::Lifeline + && element.parent == Some(owner) + && element.layer() == layer + }) +} + +/// Only actual steps of this Scenario and its own Fragment/Branch sequences. +/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph +/// cycles terminate, and malformed parent/step links do not manufacture membership. +fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet { + let mut messages = BTreeSet::new(); + let mut visited = BTreeSet::new(); + let mut pending = vec![owner]; + while let Some(scope) = pending.pop() { + if !visited.insert(scope) { + continue; + } + let Some(element) = p.elements.get(&scope) else { + continue; + }; + for id in element.steps().into_iter().flatten() { + let Some(item) = p.elements.get(id) else { + continue; + }; + if item.parent != Some(scope) || item.layer() != layer { + continue; + } + match item.shared() { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { .. }, + .. + }) => { + messages.insert(*id); + } + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) => { + for branch in branches { + if p.elements.get(branch).is_some_and(|operand| { + operand.parent == Some(*id) + && operand.layer() == layer + && matches!( + operand.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + }) + ) + }) { + pending.push(*branch); + } + } + } + _ => {} + } + } + } + messages +} + +pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec) { + if diagram.projection.scenario().is_none() { + if diagram.layout.is_some() { + validation::error( + out, + diagram.id, + "scenario_layout_projection", + "layout", + "Only a Scenario projection accepts a Scenario layout, including an empty payload.", + ); + } + return; + } + let scenario = match owner(p, diagram) { + Ok(owner) => owner, + Err(error) => { + validation::error( + out, + diagram.id, + "scenario_diagram_context", + "projection", + error.0, + ); + return; + } + }; + if !diagram.nodes.is_empty() || !diagram.routes.is_empty() { + validation::error( + out, + diagram.id, + "scenario_diagram_content", + "layout", + "Scenario views do not accept architecture node placements or edge routes.", + ); + } + let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else { + return; + }; + let mut lanes = BTreeSet::new(); + for lane in &layout.lanes { + if !lanes.insert(lane.lifeline) { + validation::error( + out, + diagram.id, + "duplicate_scenario_lane", + "layout", + "A Scenario layout has at most one position per lifeline.", + ); + } + if !lane_member(p, scenario, diagram.layer, lane.lifeline) { + validation::error( + out, + diagram.id, + "scenario_layout_lifeline", + "layout", + "Lane positions reference a Lifeline directly owned by the projected Scenario.", + ); + } + if !coordinate(lane.x) { + validation::error( + out, + diagram.id, + "scenario_layout_coordinate", + "layout", + "Scenario positions must be finite and within the shared coordinate bounds.", + ); + } + } + let members = direct_messages(p, scenario, diagram.layer); + let mut addresses = BTreeSet::new(); + for message in &layout.messages { + // Reject expanded paths without cloning or indexing their potentially large contents. + if !message.address.references.is_empty() { + validation::error( + out, + diagram.id, + "scenario_layout_address", + "layout", + "Expanded-reference message positions are not supported; references must be empty.", + ); + } else if !addresses.insert(message.address.occurrence) { + validation::error( + out, + diagram.id, + "duplicate_scenario_message", + "layout", + "A Scenario layout has at most one position per message address.", + ); + } + if !members.contains(&message.address.occurrence) { + validation::error( + out, + diagram.id, + "scenario_layout_message", + "layout", + "Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.", + ); + } + if !coordinate(message.send_y) { + validation::error( + out, + diagram.id, + "scenario_layout_coordinate", + "layout", + "Scenario positions must be finite and within the shared coordinate bounds.", + ); + } + } +} + +fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> { + let diagram = p + .diagrams + .get(&id) + .ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?; + let scenario = owner(p, diagram)?; + if !diagram.nodes.is_empty() || !diagram.routes.is_empty() { + return Err(ModelError( + "Scenario views do not accept architecture node placements or edge routes.".into(), + )); + } + Ok((diagram, scenario)) +} + +fn check_message( + p: &Project, + diagram: &Diagram, + owner: Id, + address: &OccurrenceEndpoint, +) -> Result<(), ModelError> { + if !address.references.is_empty() { + return Err(ModelError( + "Expanded-reference message positions are not supported; references must be empty." + .into(), + )); + } + if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) { + return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into())); + } + Ok(()) +} + +fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout { + let DiagramLayout::Scenario(layout) = diagram + .layout + .get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default())); + layout +} + +/// Target-scoped preparation, not whole-project or whole-layout validation. +/// Unrelated intermediate errors remain repairable by later commands in a batch. +pub fn set_lane( + p: &Project, + diagram: Id, + position: ScenarioLanePosition, +) -> Result { + let (current, owner) = editable(p, diagram)?; + if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) { + return Err(ModelError( + "Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(), + )); + } + let mut result = current.clone(); + let layout = payload(&mut result); + if layout + .lanes + .iter() + .filter(|row| row.lifeline == position.lifeline) + .count() + > 1 + { + return Err(ModelError( + "Cannot set an ambiguous duplicate lane position.".into(), + )); + } + if let Some(existing) = layout + .lanes + .iter_mut() + .find(|row| row.lifeline == position.lifeline) + { + if existing.x != position.x { + *existing = position; + } + } else { + layout.lanes.push(position); + } + Ok(result) +} + +pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result { + let (current, owner) = editable(p, diagram)?; + if !lane_member(p, owner, current.layer, lifeline) { + return Err(ModelError( + "Removing a lane position requires an owned Lifeline.".into(), + )); + } + let mut result = current.clone(); + if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout { + layout.lanes.retain(|row| row.lifeline != lifeline); + } + Ok(result) +} + +pub fn set_message( + p: &Project, + diagram: Id, + position: ScenarioMessagePosition, +) -> Result { + let (current, owner) = editable(p, diagram)?; + check_message(p, current, owner, &position.address)?; + if !coordinate(position.send_y) { + return Err(ModelError( + "Message position requires a finite bounded send_y coordinate.".into(), + )); + } + let mut result = current.clone(); + let layout = payload(&mut result); + if layout + .messages + .iter() + .filter(|row| row.address == position.address) + .count() + > 1 + { + return Err(ModelError( + "Cannot set an ambiguous duplicate message position.".into(), + )); + } + if let Some(existing) = layout + .messages + .iter_mut() + .find(|row| row.address == position.address) + { + if existing.send_y != position.send_y { + *existing = position; + } + } else { + layout.messages.push(position); + } + Ok(result) +} + +pub fn remove_message( + p: &Project, + diagram: Id, + address: &OccurrenceEndpoint, +) -> Result { + let (current, owner) = editable(p, diagram)?; + check_message(p, current, owner, address)?; + let mut result = current.clone(); + if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout { + layout.messages.retain(|row| &row.address != address); + } + Ok(result) +} + +/// Prune only identities in the caller's actual deletion closure, never visibility +/// or missing endpoint assignments. Owner-view cascade remains the caller's job. +pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet) { + if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout { + layout + .lanes + .retain(|lane| !deleted.contains(&lane.lifeline)); + layout.messages.retain(|message| { + !deleted.contains(&message.address.occurrence) + && !message + .address + .references + .iter() + .any(|id| deleted.contains(id)) + }); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-core/src/scenario_layout/tests.rs b/crates/oa-core/src/scenario_layout/tests.rs new file mode 100644 index 00000000..2251fa7b --- /dev/null +++ b/crates/oa-core/src/scenario_layout/tests.rs @@ -0,0 +1,787 @@ +use super::*; +use crate::{ + Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind, +}; +use std::collections::BTreeMap; + +struct Fixture { + project: Project, + owner: Id, + performer: Id, + lane: Id, + repeated_lane: Id, + first: Id, + second: Id, + nested: Id, + fragment: Id, + branch: Id, + view: Id, + other_view: Id, + exchange: Id, +} + +fn set_body(element: &mut Element, body: OccurrenceBody) { + *element.shared_mut().unwrap() = SharedBody::Occurrence { + body, + precondition: String::new(), + postcondition: String::new(), + }; +} +fn address(occurrence: Id) -> OccurrenceEndpoint { + OccurrenceEndpoint { + references: vec![], + occurrence, + } +} +fn diagram(owner: Id, alias: &str) -> Diagram { + Diagram { + id: Id::new_v4(), + alias: alias.into(), + name: alias.into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: BTreeMap::new(), + routes: vec![], + layout: None, + } +} +fn assert_admitted(project: &Project) { + let errors: Vec<_> = validation::validate(project) + .into_iter() + .filter(|issue| issue.severity == validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); +} +fn has_code(project: &Project, code: &str) -> bool { + validation::validate(project) + .iter() + .any(|issue| issue.code == code) +} +fn layout(diagram: &Diagram) -> &ScenarioLayout { + let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else { + panic!("expected saved payload") + }; + layout +} +fn save(project: &mut Project, replacement: Diagram) { + project.diagrams.insert(replacement.id, replacement); +} +fn fixture() -> Fixture { + let mut project = Project::empty(); + let owner = project + .add_element(Kind::Scenario, Layer::Oa, "Nominal", None) + .unwrap(); + let performer = project + .add_element(Kind::Entity, Layer::Oa, "Controller", None) + .unwrap(); + let receiver = project + .add_element(Kind::Entity, Layer::Oa, "Provider", None) + .unwrap(); + let lane = project + .add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner)) + .unwrap(); + let repeated_lane = project + .add_element( + Kind::Lifeline, + Layer::Oa, + "Controller review lane", + Some(owner), + ) + .unwrap(); + let receiver_lane = project + .add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner)) + .unwrap(); + for (id, represented) in [ + (lane, performer), + (repeated_lane, performer), + (receiver_lane, receiver), + ] { + *project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline { + represented: Some(represented), + }; + } + let exchange = Id::new_v4(); + project.relations.insert( + exchange, + Relation { + id: exchange, + alias: "delivery_channel".into(), + name: "Delivery".into(), + description: String::new(), + body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver), + }, + ); + let first = project + .add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner)) + .unwrap(); + let second = project + .add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner)) + .unwrap(); + for (id, sender) in [(first, lane), (second, repeated_lane)] { + set_body( + project.elements.get_mut(&id).unwrap(), + OccurrenceBody::Exchange { + exchange: Some(exchange), + sender: Some(sender), + receiver: Some(receiver_lane), + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + ); + } + let fragment = project + .add_element( + Kind::Occurrence, + Layer::Oa, + "Optional delivery", + Some(owner), + ) + .unwrap(); + set_body( + project.elements.get_mut(&fragment).unwrap(), + OccurrenceBody::Fragment { + operator: FragmentOperator::Optional, + branches: vec![], + }, + ); + let branch = project + .add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment)) + .unwrap(); + let nested = project + .add_element( + Kind::Occurrence, + Layer::Oa, + "Unfinished nested delivery", + Some(branch), + ) + .unwrap(); + set_body( + project.elements.get_mut(&nested).unwrap(), + OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + ); + set_body( + project.elements.get_mut(&branch).unwrap(), + OccurrenceBody::Branch { + guard: "requested".into(), + occurrences: vec![nested], + }, + ); + set_body( + project.elements.get_mut(&fragment).unwrap(), + OccurrenceBody::Fragment { + operator: FragmentOperator::Optional, + branches: vec![branch], + }, + ); + *project + .elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() = vec![first, second, fragment]; + let view_record = diagram(owner, "nominal_view"); + let other_record = diagram(owner, "review_view"); + let view = view_record.id; + let other_view = other_record.id; + save(&mut project, view_record); + save(&mut project, other_record); + assert_admitted(&project); + Fixture { + project, + owner, + performer, + lane, + repeated_lane, + first, + second, + nested, + fragment, + branch, + view, + other_view, + exchange, + } +} + +#[test] +fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() { + let mut f = fixture(); + let semantics = (f.project.elements.clone(), f.project.relations.clone()); + for (view, line, x) in [ + (f.view, f.lane, 420.), + (f.view, f.repeated_lane, 680.), + (f.other_view, f.lane, 900.), + ] { + let result = + set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap(); + save(&mut f.project, result); + } + for (view, id, y) in [ + (f.view, f.first, 200.), + (f.view, f.second, 350.), + (f.other_view, f.first, 260.), + ] { + let result = set_message( + &f.project, + view, + ScenarioMessagePosition { + address: address(id), + send_y: y, + }, + ) + .unwrap(); + save(&mut f.project, result); + } + assert_eq!( + layout(&f.project.diagrams[&f.view]).lanes, + vec![ + ScenarioLanePosition { + lifeline: f.lane, + x: 420. + }, + ScenarioLanePosition { + lifeline: f.repeated_lane, + x: 680. + }, + ] + ); + assert_eq!( + layout(&f.project.diagrams[&f.view]) + .messages + .iter() + .map(|r| (r.address.occurrence, r.send_y)) + .collect::>(), + vec![(f.first, 200.), (f.second, 350.)] + ); + assert_eq!( + layout(&f.project.diagrams[&f.other_view]).messages[0].send_y, + 260. + ); + assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.); + assert_eq!( + (f.project.elements.clone(), f.project.relations.clone()), + semantics + ); + assert_admitted(&f.project); +} + +#[test] +fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() { + let mut f = fixture(); + let result = set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.nested), + send_y: -80., + }, + ) + .unwrap(); + save(&mut f.project, result); + assert_admitted(&f.project); + let before = f.project.clone(); + let expanded = OccurrenceEndpoint { + references: vec![f.fragment], + occurrence: f.nested, + }; + assert!( + set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: expanded.clone(), + send_y: 400. + } + ) + .is_err() + ); + assert!(remove_message(&f.project, f.view, &expanded).is_err()); + assert_eq!(f.project, before); + payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded; + assert!(has_code(&f.project, "scenario_layout_address")); +} + +#[test] +fn only_ordered_message_appearances_and_owned_lifelines_are_targets() { + let mut f = fixture(); + assert!( + set_lane( + &f.project, + f.view, + ScenarioLanePosition { + lifeline: f.performer, + x: 10. + } + ) + .is_err() + ); + for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] { + assert!( + set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(id), + send_y: 100. + } + ) + .is_err() + ); + } + let foreign = f + .project + .add_element(Kind::Scenario, Layer::Oa, "Foreign", None) + .unwrap(); + let foreign_lane = f + .project + .add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign)) + .unwrap(); + assert!(remove_lane(&f.project, f.view, foreign_lane).is_err()); + f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign); + assert!( + set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.first), + send_y: 100. + } + ) + .is_err() + ); + f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner); + f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() + .retain(|id| *id != f.first); + assert!( + set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.first), + send_y: 100. + } + ) + .is_err() + ); +} + +#[test] +fn duplicate_positions_reject_even_when_values_match() { + let mut f = fixture(); + let lane = ScenarioLanePosition { + lifeline: f.lane, + x: 10., + }; + let message = ScenarioMessagePosition { + address: address(f.first), + send_y: 100., + }; + let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap()); + entries.lanes = vec![lane.clone(), lane.clone()]; + entries.messages = vec![message.clone(), message.clone()]; + assert!(has_code(&f.project, "duplicate_scenario_lane")); + assert!(has_code(&f.project, "duplicate_scenario_message")); + assert!(set_lane(&f.project, f.view, lane).is_err()); + assert!(set_message(&f.project, f.view, message).is_err()); + // Explicit removal can repair all duplicates of the targeted identity. + let result = remove_lane(&f.project, f.view, f.lane).unwrap(); + save(&mut f.project, result); + let result = remove_message(&f.project, f.view, &address(f.first)).unwrap(); + save(&mut f.project, result); + assert_admitted(&f.project); +} + +#[test] +fn sparse_absence_empty_payload_and_signed_zero_are_preserved() { + let mut f = fixture(); + let absent = f.project.diagrams[&f.view].clone(); + assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent); + assert_eq!( + remove_message(&f.project, f.view, &address(f.first)).unwrap(), + absent + ); + assert!( + serde_json::to_value(&absent) + .unwrap() + .get("layout") + .is_none() + ); + let result = set_lane( + &f.project, + f.view, + ScenarioLanePosition { + lifeline: f.lane, + x: -0., + }, + ) + .unwrap(); + save(&mut f.project, result); + let result = set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.first), + send_y: -0., + }, + ) + .unwrap(); + save(&mut f.project, result); + let result = set_lane( + &f.project, + f.view, + ScenarioLanePosition { + lifeline: f.lane, + x: 0., + }, + ) + .unwrap(); + assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits()); + let result = set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.first), + send_y: 0., + }, + ) + .unwrap(); + assert_eq!( + layout(&result).messages[0].send_y.to_bits(), + (-0.0_f32).to_bits() + ); + assert_eq!( + serde_json::to_string(&result).unwrap(), + serde_json::to_string(&f.project.diagrams[&f.view]).unwrap() + ); + let result = remove_lane(&f.project, f.view, f.lane).unwrap(); + save(&mut f.project, result); + let result = remove_message(&f.project, f.view, &address(f.first)).unwrap(); + assert_eq!( + result.layout, + Some(DiagramLayout::Scenario(ScenarioLayout::default())) + ); + assert_ne!(result, absent); +} + +#[test] +fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() { + let mut f = fixture(); + for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] { + let result = set_lane( + &f.project, + f.view, + ScenarioLanePosition { + lifeline: f.lane, + x: value, + }, + ) + .unwrap(); + save(&mut f.project, result); + let result = set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.first), + send_y: value, + }, + ) + .unwrap(); + save(&mut f.project, result); + assert_admitted(&f.project); + } + let before = serde_json::to_string(&f.project).unwrap(); + for value in [ + f32::NAN, + f32::INFINITY, + f32::NEG_INFINITY, + MAX_COORDINATE + 1., + ] { + assert!( + set_lane( + &f.project, + f.view, + ScenarioLanePosition { + lifeline: f.lane, + x: value + } + ) + .is_err() + ); + assert!( + set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.first), + send_y: value + } + ) + .is_err() + ); + } + assert_eq!(serde_json::to_string(&f.project).unwrap(), before); + payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN; + assert!(has_code(&f.project, "scenario_layout_coordinate")); +} + +#[test] +fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() { + let mut f = fixture(); + payload(f.project.diagrams.get_mut(&f.view).unwrap()) + .messages + .push(ScenarioMessagePosition { + address: address(f.second), + send_y: f32::INFINITY, + }); + let result = set_lane( + &f.project, + f.view, + ScenarioLanePosition { + lifeline: f.lane, + x: 420., + }, + ) + .unwrap(); + save(&mut f.project, result); + assert!(has_code(&f.project, "scenario_layout_coordinate")); + let result = set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.second), + send_y: 300., + }, + ) + .unwrap(); + save(&mut f.project, result); + assert_admitted(&f.project); +} + +#[test] +fn projection_context_and_architecture_payloads_are_explicitly_separate() { + let mut f = fixture(); + let scenario = f.project.diagrams[&f.view].projection; + assert_eq!(scenario.scenario(), Some(f.owner)); + assert!(!scenario.is_architecture()); + assert!(!scenario.includes(Kind::Lifeline)); + assert!(ProjectionKind::Overview.is_architecture()); + assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture()); + f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa; + assert!(has_code(&f.project, "scenario_diagram_context")); + assert!(remove_lane(&f.project, f.view, f.lane).is_err()); + f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa; + f.project + .diagrams + .get_mut(&f.view) + .unwrap() + .routes + .push(crate::EdgeRoute { + edge: f.exchange, + waypoints: vec![], + label: None, + }); + assert!(has_code(&f.project, "scenario_diagram_content")); + f.project.diagrams.get_mut(&f.view).unwrap().routes.clear(); + payload(f.project.diagrams.get_mut(&f.view).unwrap()); + f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview; + assert!(has_code(&f.project, "scenario_layout_projection")); + assert!( + set_lane( + &f.project, + f.view, + ScenarioLanePosition { + lifeline: f.lane, + x: 10. + } + ) + .is_err() + ); +} + +#[test] +fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() { + let mut f = fixture(); + let result = set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.first), + send_y: 200., + }, + ) + .unwrap(); + save(&mut f.project, result); + let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap(); + assert_eq!( + serde_json::from_value::(encoded.clone()).unwrap(), + f.project.diagrams[&f.view] + ); + assert_eq!( + encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"], + serde_json::json!(f.first) + ); + let mut unknown = encoded.clone(); + unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1); + assert!(serde_json::from_value::(unknown).is_err()); + let mut missing = encoded; + missing["layout"]["Scenario"]["messages"][0]["address"] + .as_object_mut() + .unwrap() + .remove("references"); + assert!(serde_json::from_value::(missing).is_err()); +} + +#[test] +fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() { + let mut f = fixture(); + let result = set_lane( + &f.project, + f.view, + ScenarioLanePosition { + lifeline: f.lane, + x: 420., + }, + ) + .unwrap(); + save(&mut f.project, result); + let result = set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: address(f.first), + send_y: 200., + }, + ) + .unwrap(); + save(&mut f.project, result); + let mut copied = f.project.diagrams[&f.view].clone(); + copied.id = f.other_view; + copied.alias = "review_view".into(); + copied.name = "Review view".into(); + save(&mut f.project, copied.clone()); + assert_admitted(&f.project); + assert_eq!(layout(&copied).messages[0].address.occurrence, f.first); + let mut candidate = f.project.diagrams[&f.view].clone(); + prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange])); + assert!(layout(&candidate).lanes.is_empty()); + assert_eq!(layout(&candidate).messages.len(), 1); + prune_deleted(&mut candidate, &BTreeSet::from([f.first])); + assert!(layout(&candidate).messages.is_empty()); + assert!(candidate.layout.is_some()); + assert_eq!(f.project.diagrams[&f.other_view], copied); +} + +#[test] +fn reference_visitor_and_record_count_cover_explicit_rows() { + let f = fixture(); + let mut d = f.project.diagrams[&f.view].clone(); + assert_eq!(record_count(&d), 0); + let rows = payload(&mut d); + rows.lanes.push(ScenarioLanePosition { + lifeline: f.lane, + x: 50., + }); + rows.messages.push(ScenarioMessagePosition { + address: address(f.first), + send_y: 100., + }); + assert_eq!(record_count(&d), 3); + let replacement = Id::new_v4(); + d.layout.as_mut().unwrap().visit_references(|id| { + if *id == f.first { + *id = replacement; + } + }); + assert_eq!(layout(&d).messages[0].address.occurrence, replacement); + assert_eq!(layout(&d).messages[0].send_y, 100.); + assert_eq!(layout(&d).lanes[0].lifeline, f.lane); +} + +#[test] +fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() { + let mut f = fixture(); + let called = f + .project + .add_element(Kind::Scenario, Layer::Oa, "Called sequence", None) + .unwrap(); + let called_message = f + .project + .add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called)) + .unwrap(); + set_body( + f.project.elements.get_mut(&called_message).unwrap(), + OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + ); + *f.project + .elements + .get_mut(&called) + .unwrap() + .steps_mut() + .unwrap() = vec![called_message]; + let usage = f + .project + .add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner)) + .unwrap(); + set_body( + f.project.elements.get_mut(&usage).unwrap(), + OccurrenceBody::ScenarioUse { + scenario: Some(called), + bindings: vec![], + }, + ); + f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() + .push(usage); + assert_admitted(&f.project); + for target in [ + address(called_message), + address(usage), + OccurrenceEndpoint { + references: vec![usage], + occurrence: called_message, + }, + ] { + assert!( + set_message( + &f.project, + f.view, + ScenarioMessagePosition { + address: target, + send_y: 200. + } + ) + .is_err() + ); + } +} + +mod source_lifecycle; diff --git a/crates/oa-core/src/scenario_layout/tests/source_lifecycle.rs b/crates/oa-core/src/scenario_layout/tests/source_lifecycle.rs new file mode 100644 index 00000000..a5411ec7 --- /dev/null +++ b/crates/oa-core/src/scenario_layout/tests/source_lifecycle.rs @@ -0,0 +1,245 @@ +//! Source/command lifecycle checks for the candidate payload. Public version +//! assignment remains the coordinated codec integration gate. +use super::*; +use crate::{Command, Session, language, prepare_command, source_project::*}; + +fn authored(f: &Fixture) -> CompiledSourceProject { + let mut bundle = format_bundle(&f.project); + let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + let location = &initial.index.declarations[&f.view]; + let source = bundle.files.get_mut(&location.path).unwrap(); + let text = format!( + r#"diagram nominal_view "Nominal review" id "{}" {{ + projection {{ Scenario {{ scenario @{}; }}; }}; + layout {{ Scenario {{ + lanes [ + // Controller spacing rationale. + {{ lifeline @ // first lane identity + {}; x 4.20e2; }}, + // Repeated performer has its own lane. + {{ lifeline @{}; x 680; }} + ]; + messages [ + // First delivery timing rationale. + {{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }}, + // Second delivery timing rationale. + {{ address {{ references []; occurrence @{}; }}; send_y 350; }} + ]; + }}; }}; +}} +"#, + f.view, + f.project.elements[&f.owner].alias, + f.project.elements[&f.lane].alias, + f.project.elements[&f.repeated_lane].alias, + f.project.elements[&f.first].alias, + f.project.elements[&f.second].alias + ); + source.replace_range(location.span.start..location.span.end, &text); + compile_bundle(&bundle, ProjectLimits::default()).unwrap() +} + +#[test] +fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() { + let f = fixture(); + let original = authored(&f); + let mut changed = original.decoded.project.clone(); + let rows = payload(changed.diagrams.get_mut(&f.view).unwrap()); + rows.lanes.swap(0, 1); + rows.messages.swap(0, 1); + rows.messages[0].send_y = 390.; + changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into(); + let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap(); + assert_eq!(updated.decoded.project, changed); + let path = &original.index.declarations[&f.view].path; + assert_eq!(&updated.index.declarations[&f.view].path, path); + let text = &updated.bundle.files[path]; + assert!( + text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(), + "{text}" + ); + assert!( + text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(), + "{text}" + ); + assert!( + text.contains( + "lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;" + ), + "{text}" + ); + assert!(text.contains("send_y 2.00e2"), "{text}"); + assert!(text.contains("send_y 390"), "{text}"); + for (other, bytes) in &original.bundle.files { + if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) { + assert_eq!(&updated.bundle.files[other], bytes); + } + } + assert_eq!( + compile_bundle(&updated.bundle, ProjectLimits::default()) + .unwrap() + .decoded + .project, + changed + ); +} + +#[test] +fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() { + let f = fixture(); + let original = authored(&f); + let temporary = Id::new_v4(); + let command = Command::Batch(vec![ + Command::Reidentify(f.first, temporary), + Command::Reidentify(f.second, f.first), + Command::Reidentify(temporary, f.second), + ]); + let prepared = prepare_command(&original.decoded.project, &command).unwrap(); + let mut changed = prepared.project; + payload(changed.diagrams.get_mut(&f.view).unwrap()) + .messages + .swap(0, 1); + let updated = update_bundle_with_identity_map( + &original, + &changed, + &prepared.identity_map, + ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(updated.decoded.project, changed); + let path = &original.index.declarations[&f.view].path; + let text = &updated.bundle.files[path]; + assert!( + text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(), + "{text}" + ); + let first_comment = text.find("// First delivery").unwrap(); + assert!(text[first_comment..].contains(&format!( + "occurrence @{}", + changed.elements[&f.second].alias + ))); + assert!(text[first_comment..].contains("send_y 2.00e2")); +} + +#[test] +fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() { + let f = fixture(); + let original = authored(&f); + let mut session = Session::new(original.decoded.project.clone()); + session + .execute(Command::Batch(vec![ + Command::SetScenarioLanePosition { + diagram: f.view, + position: ScenarioLanePosition { + lifeline: f.lane, + x: 510., + }, + }, + Command::SetScenarioMessagePosition { + diagram: f.view, + position: ScenarioMessagePosition { + address: address(f.second), + send_y: 430., + }, + }, + ])) + .unwrap(); + let changed = session.project().clone(); + assert_eq!(session.revision(), 1); + assert_eq!(changed.elements, original.decoded.project.elements); + assert_eq!(changed.relations, original.decoded.project.relations); + let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap(); + session.undo(); + assert_eq!(session.project(), &original.decoded.project); + assert!(!session.can_undo()); + session.redo(); + assert_eq!(session.project(), &changed); + session + .execute(Command::SetScenarioMessagePosition { + diagram: f.view, + position: ScenarioMessagePosition { + address: address(f.second), + send_y: 430., + }, + }) + .unwrap(); + assert_eq!(session.revision(), 3); + let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap(); + assert_eq!(noop.bundle, updated.bundle); +} + +#[test] +fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() { + let f = fixture(); + let before = language::format(&f.project); + let mut session = Session::new(f.project.clone()); + session + .execute(Command::RemoveScenarioLanePosition { + diagram: f.view, + lifeline: f.lane, + }) + .unwrap(); + assert_eq!(session.project(), &f.project); + assert!(!session.can_undo()); + assert_eq!(language::format(session.project()), before); + let mut present = f.project.clone(); + present.diagrams.get_mut(&f.view).unwrap().layout = + Some(DiagramLayout::Scenario(ScenarioLayout::default())); + let text = language::format(&present); + assert_ne!(text, before); + assert_eq!(language::parse(&text).unwrap(), present); + assert_eq!(language::parse(&before).unwrap(), f.project); +} + +#[test] +fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() { + let f = fixture(); + let original = authored(&f); + let before = original.bundle.clone(); + let path = original.index.declarations[&f.view].path.clone(); + for replacement in ["x 1e30", "x not_a_number"] { + let mut candidate = before.clone(); + let source = candidate.files.get_mut(&path).unwrap(); + *source = source.replace("x 4.20e2", replacement); + assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err()); + assert_eq!(original.bundle, before); + } + let mut candidate = before.clone(); + let source = candidate.files.get_mut(&path).unwrap(); + *source = source.replace( + &format!("occurrence @{}", f.project.elements[&f.first].alias), + &format!("occurrence @{}", f.project.elements[&f.performer].alias), + ); + assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err()); + assert_eq!(original.bundle, before); +} + +#[test] +fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() { + let f = fixture(); + let mut bundle = format_bundle(&f.project); + let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + let location = &compiled.index.declarations[&f.view]; + let path = location.path.clone(); + bundle.files.get_mut(&path).unwrap().insert_str( + location.span.end - 1, + "\n layout // explicitly automatic\n none;\n", + ); + let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + let mut described = original.decoded.project.clone(); + described.diagrams.get_mut(&f.view).unwrap().description = + "Review spacing independently".into(); + let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap(); + assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;")); + assert_eq!(updated.decoded.project, described); + let mut empty = described.clone(); + empty.diagrams.get_mut(&f.view).unwrap().layout = + Some(DiagramLayout::Scenario(ScenarioLayout::default())); + let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap(); + assert_eq!(present.decoded.project, empty); + assert!(present.bundle.files[&path].contains("// explicitly automatic")); + let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap(); + assert_eq!(cleared.decoded.project, described); + assert!(!cleared.bundle.files[&path].contains("layout")); + assert!(cleared.bundle.files[&path].contains("// explicitly automatic")); +} diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index 0e740810..c9f65c7d 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -23,6 +23,22 @@ pub enum Command { EditRelation(Id, Relation), CreateDiagram(Diagram), EditDiagram(Id, Diagram), + SetScenarioLanePosition { + diagram: Id, + position: ScenarioLanePosition, + }, + RemoveScenarioLanePosition { + diagram: Id, + lifeline: Id, + }, + SetScenarioMessagePosition { + diagram: Id, + position: ScenarioMessagePosition, + }, + RemoveScenarioMessagePosition { + diagram: Id, + address: OccurrenceEndpoint, + }, CreateArtifact(ArtifactLink), EditArtifact(Id, ArtifactLink), CreateTrace(Trace), @@ -488,6 +504,26 @@ fn apply_command( reidentify_tracked(p, *old, value.id, provenance)?; Some(value.id) } + Command::SetScenarioLanePosition { diagram, position } => { + let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?; + p.diagrams.insert(*diagram, replacement); + Some(*diagram) + } + Command::RemoveScenarioLanePosition { diagram, lifeline } => { + let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?; + p.diagrams.insert(*diagram, replacement); + Some(*diagram) + } + Command::SetScenarioMessagePosition { diagram, position } => { + let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?; + p.diagrams.insert(*diagram, replacement); + Some(*diagram) + } + Command::RemoveScenarioMessagePosition { diagram, address } => { + let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?; + p.diagrams.insert(*diagram, replacement); + Some(*diagram) + } Command::CreateArtifact(value) => { vacant(p, value.id)?; p.artifact_links.insert(value.id, value.clone()); @@ -878,6 +914,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> { return Ok(()); } vacant(p, new)?; + crate::references::check_remap(p, old, new)?; if p.id == old { p.id = new; } else if let Some(mut v) = p.elements.remove(&old) { @@ -904,12 +941,17 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> { } } } - crate::references::remap_references(p, old, new); + crate::references::remap_references(p, old, new)?; Ok(()) } fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> { use std::collections::{BTreeMap, BTreeSet}; let d = p.diagrams.get(&diagram).ok_or_else(missing)?; + if d.projection.scenario().is_some() { + return Err(ModelError( + "Scenario layout requires the Scenario geometry operation.".into(), + )); + } let parents = crate::projection::parents(p, d); let mut children: BTreeMap, Vec> = BTreeMap::new(); for (id, parent) in &parents { diff --git a/crates/oa-core/src/source_project.rs b/crates/oa-core/src/source_project.rs index 4d3e377f..8db86bd5 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -930,7 +930,18 @@ pub fn update_bundle_with_identity_map( }; let local = edits.entry(path.clone()).or_default(); update.header(&actual.header, &previous.header, &next.header, local); - update.value(&actual.fields, Some(&previous.fields), &next.fields, local); + let context = if old.diagrams.contains_key(&id) { + EditContext::Diagram + } else { + EditContext::Other + }; + update.value( + &actual.fields, + Some(&previous.fields), + &next.fields, + context, + local, + ); } } if old.id != project.id || old.alias != project.alias || old.name != project.name { @@ -1119,6 +1130,29 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String { output } +// Schema context is explicit: similarly named fields in arbitrary records must +// never acquire Scenario row identity or move their authored trivia. +#[derive(Clone, Copy)] +enum EditContext { + Other, + Diagram, + Layout, + Scenario, + Lanes, + Messages, +} +impl EditContext { + fn field(self, name: &str) -> Self { + match (self, name) { + (Self::Diagram, "layout") => Self::Layout, + (Self::Layout, "Scenario") => Self::Scenario, + (Self::Scenario, "lanes") => Self::Lanes, + (Self::Scenario, "messages") => Self::Messages, + _ => Self::Other, + } + } +} + struct SyntaxUpdate<'a> { source: &'a str, before: &'a str, @@ -1185,6 +1219,7 @@ impl SyntaxUpdate<'_> { actual: &language::EditValue, before: Option<&language::EditValue>, after: &language::EditValue, + context: EditContext, edits: &mut LocalEdits, ) { use language::EditKind; @@ -1200,7 +1235,7 @@ impl SyntaxUpdate<'_> { None } }); - self.record(actual.span, fields, previous, next, edits); + self.record(actual.span, fields, previous, next, context, edits); } (EditKind::List(items), EditKind::List(next)) => { let previous = before.and_then(|old| { @@ -1210,7 +1245,7 @@ impl SyntaxUpdate<'_> { None } }); - self.list(actual.span, items, previous, next, edits); + self.list(actual.span, items, previous, next, context, edits); } (EditKind::Reference(_), EditKind::Reference(alias)) => { let token = significant(&self.source[actual.span.start..actual.span.end]) @@ -1263,6 +1298,7 @@ impl SyntaxUpdate<'_> { actual: &BTreeMap, before: Option<&BTreeMap>, after: &BTreeMap, + context: EditContext, edits: &mut LocalEdits, ) { // A typed constructor tag change retains shared fields and their trivia. @@ -1286,7 +1322,13 @@ impl SyntaxUpdate<'_> { }, new_key.clone(), )); - self.value(&field.value, Some(&old.value), &new.value, edits); + self.value( + &field.value, + Some(&old.value), + &new.value, + context.field(new_key), + edits, + ); return; } } @@ -1298,9 +1340,16 @@ impl SyntaxUpdate<'_> { .and_then(|fields| fields.get(key)) .map(|field| &field.value), &next.value, + context.field(key), edits, ); - } else { + } else if !(matches!(context, EditContext::Diagram) + && key == "layout" + && before.is_some_and(|fields| !fields.contains_key(key))) + { + // Canonical None omits layout. Preserve an authored explicit + // none while another field changes; a real Some->None clear + // still removes the former payload and retains its trivia. erase_syntax(self.source, field.span, edits); } } @@ -1355,6 +1404,7 @@ impl SyntaxUpdate<'_> { actual: &[language::EditValue], before: Option<&[language::EditValue]>, after: &[language::EditValue], + context: EditContext, edits: &mut LocalEdits, ) { let previous = before.unwrap_or(&[]); @@ -1367,6 +1417,7 @@ impl SyntaxUpdate<'_> { self.old_aliases, self.legacy_value_paths, None, + context, ) }) .collect(); @@ -1379,6 +1430,7 @@ impl SyntaxUpdate<'_> { self.new_aliases, self.legacy_value_paths, self.identity_origins, + context, ) }) .collect(); @@ -1407,7 +1459,7 @@ impl SyntaxUpdate<'_> { .all(|(i, source)| *source == Some(i)) { for (i, (current, next)) in actual.iter().zip(after).enumerate() { - self.value(current, previous.get(i), next, edits); + self.value(current, previous.get(i), next, EditContext::Other, edits); } return; } @@ -1440,7 +1492,13 @@ impl SyntaxUpdate<'_> { let chunk = chunks[index]; text.push_str(&self.source[chunk.start..current.span.start]); let mut local = Vec::new(); - self.value(current, previous.get(index), next, &mut local); + self.value( + current, + previous.get(index), + next, + EditContext::Other, + &mut local, + ); text.push_str(&changed_text(self.source, current.span, local)); suffix = &self.source[current.span.end..chunk.end]; } else { @@ -1483,14 +1541,58 @@ impl SyntaxUpdate<'_> { } } +fn scenario_item_key( + value: &language::EditValue, + aliases: &BTreeMap, + context: EditContext, +) -> Option { + use language::EditKind; + let EditKind::Record(fields) = &value.kind else { + return None; + }; + let reference = |value: &language::EditValue| { + if let EditKind::Reference(alias) = &value.kind { + aliases.get(alias).cloned() + } else { + None + } + }; + match context { + EditContext::Lanes => { + reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}")) + } + EditContext::Messages => { + let EditKind::Record(address) = &fields.get("address")?.value.kind else { + return None; + }; + let occurrence = reference(&address.get("occurrence")?.value)?; + let path: Vec<_> = match address.get("references") { + None => Vec::new(), + Some(field) => { + let EditKind::List(path) = &field.value.kind else { + return None; + }; + path.iter().map(reference).collect::>>()? + } + }; + Some(format!("scenario-message:{}:{occurrence}", path.join("/"))) + } + _ => None, + } +} + fn item_key( value: &language::EditValue, source: &str, aliases: &BTreeMap, legacy_value_paths: bool, origins: Option<&BTreeMap>, + context: EditContext, ) -> Option { use language::EditKind; + if matches!(context, EditContext::Lanes | EditContext::Messages) { + return scenario_item_key(value, aliases, context); + } if matches!(value.kind, EditKind::Record(_)) && let Some(key) = language::semantic_row_key( &source[value.span.start..value.span.end], diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index 3a121067..a8b0f76c 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -619,6 +619,7 @@ pub fn validate(p: &Project) -> Vec { } text(&mut out, d.id, "name", &d.name); text(&mut out, d.id, "description", &d.description); + crate::scenario_layout::validate_diagram(p, d, &mut out); let option_issues = if d.projection == ProjectionKind::Traceability { crate::view_options::validate_options_cross_layer(p, &d.view_options) } else { diff --git a/crates/oa-core/tests/release_routing.rs b/crates/oa-core/tests/release_routing.rs index c7e596c1..4395ee50 100644 --- a/crates/oa-core/tests/release_routing.rs +++ b/crates/oa-core/tests/release_routing.rs @@ -41,6 +41,7 @@ fn fixture() -> Fixture { projection: ProjectionKind::Overview, view_options: Default::default(), nodes: BTreeMap::new(), + layout: None, routes: vec![], }, ); diff --git a/crates/oa-core/tests/source7_contract.rs b/crates/oa-core/tests/source7_contract.rs index d33a2ea7..62e5e647 100644 --- a/crates/oa-core/tests/source7_contract.rs +++ b/crates/oa-core/tests/source7_contract.rs @@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() { assert_eq!(session.project(), &after); let fresh = Id::new_v4(); let mut reidentified = after.clone(); - references::remap_references(&mut reidentified, f.r1, fresh); + references::remap_references(&mut reidentified, f.r1, fresh).unwrap(); let mut e = reidentified.elements.remove(&f.r1).unwrap(); e.id = fresh; reidentified.elements.insert(fresh, e); diff --git a/crates/oa-core/tests/state_diagrams.rs b/crates/oa-core/tests/state_diagrams.rs index 959dc940..4f922092 100644 --- a/crates/oa-core/tests/state_diagrams.rs +++ b/crates/oa-core/tests/state_diagrams.rs @@ -20,6 +20,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id { projection: ProjectionKind::StateMachine { machine }, view_options: Default::default(), nodes: BTreeMap::new(), + layout: None, routes: vec![], }, ); diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 8b1edac3..992bf5eb 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -16,6 +16,7 @@ mod source_symbols; mod transition_context_tests; #[cfg(test)] mod view_copy_tests; +pub(crate) const SAVED_SCENARIO_UNAVAILABLE: &str = "Saved Scenario layout rendering and canvas editing are not available yet. Select the Scenario in Behavior to use its existing semantic preview."; use crate::forms::Draft; use crate::{ behavior, @@ -577,7 +578,7 @@ impl App { let active_diagram = project .diagrams .values() - .filter(|d| d.layer == layer) + .filter(|d| d.layer == layer && d.projection.scenario().is_none()) .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) .map(|d| d.id); let draft = Draft::from_project(&project, selected); @@ -798,7 +799,7 @@ impl App { .project() .diagrams .values() - .filter(|d| d.layer == self.layer) + .filter(|d| d.layer == self.layer && d.projection.scenario().is_none()) .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) .map(|d| d.id); } @@ -1050,7 +1051,7 @@ impl App { self.project() .diagrams .get(id) - .is_some_and(|d| d.layer == layer) + .is_some_and(|d| d.layer == layer && d.projection.scenario().is_none()) }) .or(retained) .or_else(|| self.diagram_for(layer, target)); @@ -1746,7 +1747,49 @@ impl App { } self.behavior.scenario.focus_stamp(self.scenario_context()?) } + fn saved_scenario_diagram(&self, id: Id) -> bool { + self.project() + .diagrams + .get(&id) + .is_some_and(|d| d.projection.scenario().is_some()) + } + + fn unsupported_saved_scenario_message(&self, message: &Message) -> bool { + let active = self + .active_diagram + .is_some_and(|id| self.saved_scenario_diagram(id)); + match message { + Message::Diagram(choice) => self.saved_scenario_diagram(choice.id), + Message::RevealInDiagram(diagram, _) | Message::Command(Command::Layout(diagram)) => { + self.saved_scenario_diagram(*diagram) + } + Message::Projection(projection) => active || projection.scenario().is_some(), + Message::SelectNode(_) + | Message::PlaceSelected + | Message::HideSelected + | Message::ToggleCollapse + | Message::MoveNode(..) + | Message::Route(_) + | Message::Connection(_) + | Message::PortAnchor(_) + | Message::Export + | Message::Fit + | Message::Zoom(_) + | Message::Pan(_) => active, + Message::Add(_) | Message::CreateContextElement(..) => { + active && self.page == Page::Diagram + } + _ => false, + } + } + fn handle(&mut self, message: Message) -> Task { + // Refuse unsupported saved-view actions before they can Apply unrelated drafts. + // Generic model/source edits and the semantic Scenario workspace remain available. + if self.unsupported_saved_scenario_message(&message) { + self.status = SAVED_SCENARIO_UNAVAILABLE.into(); + return Task::none(); + } match message { Message::BehaviorControl(intent) => return self.behavior_control(intent), Message::Architecture(event) => return self.architecture_event(event), @@ -2394,7 +2437,7 @@ impl App { self.project() .diagrams .get(&id) - .is_some_and(|d| d.projection.state_machine().is_some()) + .is_some_and(|d| !d.projection.is_architecture()) }) { // Architecture remains reachable from a state workspace. @@ -2403,9 +2446,7 @@ impl App { .project() .diagrams .values() - .filter(|d| { - d.layer == self.layer && d.projection.state_machine().is_none() - }) + .filter(|d| d.layer == self.layer && d.projection.is_architecture()) .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) .map(|d| d.id); self.fit(); @@ -2663,7 +2704,7 @@ impl App { .project() .diagrams .values() - .filter(|d| d.layer == layer) + .filter(|d| d.layer == layer && d.projection.scenario().is_none()) .min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id)) .map(|d| d.id); self.selected = self.project().id; @@ -2836,6 +2877,10 @@ impl App { let Some(view) = self.project().diagrams.get(&diagram) else { return Task::none(); }; + if view.projection.scenario().is_some() { + self.status = SAVED_SCENARIO_UNAVAILABLE.into(); + return Task::none(); + } let layer = view.layer; let endpoints = self .project() @@ -2903,6 +2948,7 @@ impl App { description: String::new(), layer: self.layer, projection: ProjectionKind::Allocation, + layout: None, routes: Vec::new(), view_options: Default::default(), nodes: Default::default(), @@ -3079,6 +3125,10 @@ impl App { .and_then(|id| self.project().diagrams.get(&id)) .cloned() { + if d.projection.scenario().is_some() { + self.status = SAVED_SCENARIO_UNAVAILABLE.into(); + return Task::none(); + } d.projection = projection; self.execute(Command::EditDiagram(d.id, d)); self.fit(); @@ -3705,7 +3755,9 @@ impl App { }; let id = e.id; let mut commands = vec![Command::CreateElement(e)]; - let mut diagram_target = self.active_diagram; + let mut diagram_target = self + .active_diagram + .filter(|id| !self.saved_scenario_diagram(*id)); let preferred = if self.create_kind.is_work() || self.create_kind.is_performer() { Some(ProjectionKind::Allocation) } else if ProjectionKind::Capabilities.includes(self.create_kind) { @@ -3735,6 +3787,7 @@ impl App { description: String::new(), layer: self.layer, projection: preferred, + layout: None, routes: Vec::new(), view_options: Default::default(), nodes: Default::default(), @@ -4074,6 +4127,13 @@ impl App { ) } fn export_to(&mut self, path: PathBuf) -> Task { + if self + .active_diagram + .is_some_and(|id| self.saved_scenario_diagram(id)) + { + self.status = SAVED_SCENARIO_UNAVAILABLE.into(); + return Task::none(); + } let Some(diagram) = self.active_diagram else { self.status = "Create a diagram first.".into(); return Task::none(); @@ -4301,6 +4361,9 @@ impl App { ) } pub fn route_context(&self) -> Option { + if self.saved_scenario_diagram(self.active_diagram?) { + return None; + } Some(crate::route_edit::Context { document: self.canvas_document, diagram: self.active_diagram?, @@ -4525,6 +4588,12 @@ impl App { } } pub fn fit(&mut self) { + if self + .active_diagram + .is_some_and(|id| self.saved_scenario_diagram(id)) + { + return; + } self.fit_to_view = true; let view = self .active_diagram @@ -4572,7 +4641,7 @@ fn preferred_diagram(p: &Project, active: Option, layer: Layer, element: Id) return p .diagrams .values() - .filter(|d| d.layer == layer) + .filter(|d| d.layer == layer && d.projection.scenario().is_none()) .min_by_key(|d| { let view = project_view(p, d.id); ( @@ -4586,7 +4655,7 @@ fn preferred_diagram(p: &Project, active: Option, layer: Layer, element: Id) } p.diagrams .values() - .filter(|d| d.layer == layer) + .filter(|d| d.layer == layer && d.projection.scenario().is_none()) .min_by_key(|d| { ( !p.elements @@ -5389,6 +5458,7 @@ mod port_view_tests { description: String::new(), layer: Layer::Sa, projection: ProjectionKind::Capabilities, + layout: None, routes: Vec::new(), view_options: Default::default(), nodes: Default::default(), @@ -5555,3 +5625,160 @@ mod data_paging_lifecycle_tests { assert_eq!(app.data_state, crate::data_view::State::default()); } } + +#[cfg(test)] +mod saved_scenario_dispatch_tests { + use super::*; + + fn fixture() -> (App, Id, Id, Id) { + let f = behavior::scenario::direct_tests::fixture(); + let mut project = f.project; + let architecture = project.ensure_diagram(Layer::Oa); + let saved = Id::new_v4(); + project.diagrams.insert( + saved, + Diagram { + id: saved, + alias: "saved_scenario_dispatch".into(), + name: "Saved Scenario".into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: f.owner }, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + layout: None, + }, + ); + let mut app = App::with_project(project); + app.page = Page::Diagram; + app.active_diagram = Some(architecture); + app.select(f.owner); + (app, architecture, saved, f.owner) + } + + #[test] + fn unsupported_saved_picker_and_reveal_do_not_apply_pending_properties() { + for reveal in [false, true] { + let (mut app, architecture, saved, owner) = fixture(); + let _ = app.update(Message::Field( + "/name".into(), + "Unapplied scenario name".into(), + )); + assert!(app.draft_dirty()); + let before = app.document.bundle().clone(); + let revision = app.document.revision(); + let selected = app.selected; + let message = if reveal { + Message::RevealInDiagram(saved, owner) + } else { + Message::Diagram(Choice { + id: saved, + label: "Saved Scenario".into(), + }) + }; + let _ = app.update(message); + assert_eq!(app.document.bundle(), &before); + assert_eq!(app.document.revision(), revision); + assert_eq!(app.active_diagram, Some(architecture)); + assert_eq!(app.selected, selected); + assert!(app.draft_dirty()); + assert_eq!(app.status, SAVED_SCENARIO_UNAVAILABLE); + } + } + + #[test] + fn saved_scenario_cannot_use_architecture_controls_or_change_projection_by_toolbar() { + let (mut app, _, saved, _) = fixture(); + app.active_diagram = Some(saved); + let _ = app.update(Message::Field("/name".into(), "Keep pending".into())); + let before = app.document.bundle().clone(); + assert!(app.route_context().is_none()); + assert!(architecture_authoring::kinds(&app).is_empty()); + for message in [ + Message::Projection(ProjectionKind::Overview), + Message::PlaceSelected, + Message::Command(Command::Layout(saved)), + Message::Export, + ] { + let _ = app.update(message); + assert_eq!(app.document.bundle(), &before); + assert!(app.draft_dirty()); + assert!(app.file_prompt.is_none()); + assert!( + app.project().diagrams[&saved] + .projection + .scenario() + .is_some() + ); + } + } + + #[test] + fn saved_scenario_does_not_start_the_existing_architecture_palette() { + let (mut app, _, saved, _) = fixture(); + app.active_diagram = Some(saved); + let before = app.document.bundle().clone(); + let _ = app.update(Message::Architecture( + architecture_authoring::Event::Existing(None), + )); + assert!(app.architecture.is_none()); + assert_eq!(app.document.bundle(), &before); + assert!(app.status.contains("architecture view")); + } + + #[test] + fn forged_saved_usage_is_refused_without_selection_or_camera_changes() { + let (mut app, architecture, saved, _) = fixture(); + let before = ( + app.page, + app.selected, + app.layer, + app.zoom, + app.pan, + app.canvas_epoch, + ); + let source = app.document.bundle().clone(); + let usage = crate::diagram_usage::Usage { + subject: saved, + surface: crate::diagram_usage::Surface::Saved(saved), + target: crate::diagram_usage::Target::Whole, + role: "diagram".into(), + bounds: iced::Rectangle::new(iced::Point::new(500., 600.), iced::Size::new(300., 200.)), + }; + assert!(!app.reveal_diagram_usage(&usage)); + assert_eq!( + ( + app.page, + app.selected, + app.layer, + app.zoom, + app.pan, + app.canvas_epoch + ), + before + ); + assert_eq!(app.active_diagram, Some(architecture)); + assert!(app.pending_diagram_reveal.is_none()); + assert_eq!(app.document.bundle(), &source); + } + + #[test] + fn semantic_scenario_preview_remains_available_and_saved_views_are_not_default_architecture() { + let (mut app, architecture, saved, owner) = fixture(); + assert_eq!( + preferred_diagram(app.project(), Some(saved), Layer::Oa, owner), + Some(architecture) + ); + app.active_diagram = Some(saved); + let before = app.document.bundle().clone(); + let _ = app.update(Message::Select(owner)); + let _ = app.update(Message::Page(Page::Behavior)); + assert_eq!(app.scenario_context().unwrap().scenario, owner); + assert_eq!(app.document.bundle(), &before); + let mut only_saved = app.project().clone(); + only_saved.diagrams.retain(|id, _| *id == saved); + assert!(preferred_diagram(&only_saved, None, Layer::Oa, owner).is_none()); + assert!(App::with_project(only_saved).active_diagram.is_none()); + } +} diff --git a/crates/oa-desktop/src/app/architecture_authoring.rs b/crates/oa-desktop/src/app/architecture_authoring.rs index 5685c611..26a8c00b 100644 --- a/crates/oa-desktop/src/app/architecture_authoring.rs +++ b/crates/oa-desktop/src/app/architecture_authoring.rs @@ -138,7 +138,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> { .diagrams .get(&s.diagram) .ok_or("The creation draft's diagram is missing.")?; - if matches!(d.projection, ProjectionKind::StateMachine { .. }) + if !d.projection.is_architecture() || s.query.len() > 16384 || s.page > 1_000_000 || s.point @@ -180,7 +180,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> { } fn context_kinds(p: &Project, d: &Diagram) -> Vec { let mut values = match (d.layer, d.projection) { - (_, ProjectionKind::StateMachine { .. }) => vec![], + (_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![], (Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity], (Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function], (Layer::Oa, ProjectionKind::Capabilities) => vec![ @@ -208,11 +208,12 @@ pub fn kinds(app: &App) -> Vec { .unwrap_or_default() } fn compatible(p: &Project, d: &Diagram, id: Id) -> bool { - p.elements.get(&id).is_some_and(|e| { - e.kind() != Kind::Port - && d.projection.includes(e.kind()) - && (e.layer() == d.layer || d.projection == ProjectionKind::Traceability) - }) + d.projection.is_architecture() + && p.elements.get(&id).is_some_and(|e| { + e.kind() != Kind::Port + && d.projection.includes(e.kind()) + && (e.layer() == d.layer || d.projection == ProjectionKind::Traceability) + }) } fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool { // Existing System remains a useful choice even when the singleton exists. @@ -257,6 +258,17 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> { } impl App { pub(super) fn architecture_event(&mut self, event: Event) -> Task { + if matches!( + &event, + Event::Start(..) | Event::Allocate(..) | Event::Existing(..) + ) && !self + .active_diagram + .and_then(|id| self.project().diagrams.get(&id)) + .is_some_and(|d| d.projection.is_architecture()) + { + self.status = "Choose an architecture view before adding architecture content.".into(); + return Task::none(); + } match event { Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => { if self.architecture.is_some() { diff --git a/crates/oa-desktop/src/app/diagram_reveal/tests.rs b/crates/oa-desktop/src/app/diagram_reveal/tests.rs index 2e27aa75..9b78e668 100644 --- a/crates/oa-desktop/src/app/diagram_reveal/tests.rs +++ b/crates/oa-desktop/src/app/diagram_reveal/tests.rs @@ -98,6 +98,7 @@ fn fixture(canvas: Canvas) -> (App, Usage) { layer: Layer::Oa, projection: ProjectionKind::StateMachine { machine }, nodes: BTreeMap::new(), + layout: None, routes: vec![], view_options: Default::default(), }, diff --git a/crates/oa-desktop/src/app/diagram_usages.rs b/crates/oa-desktop/src/app/diagram_usages.rs index fb17862f..bf4b5a18 100644 --- a/crates/oa-desktop/src/app/diagram_usages.rs +++ b/crates/oa-desktop/src/app/diagram_usages.rs @@ -58,6 +58,10 @@ impl App { let process = presentation.into_query_state(); let scenario = behavior::scenario::State::default(); usage::collect(&project, context.subject, &process, &scenario) + .into_iter() + .filter(|row| !matches!(row.surface, Surface::Saved(id) + if project.diagrams.get(&id).is_some_and(|d| d.projection.scenario().is_some()))) + .collect() }) .await .map_err(|error| format!("Diagram lookup failed: {error}. Close and try again.")); @@ -290,6 +294,9 @@ impl App { if !self.usage_navigation_available() { return false; } + if matches!(usage.surface, Surface::Saved(id) if self.saved_scenario_diagram(id)) { + return false; + } let previous_layer = self.layer; let p = self.document.project(); let (selected, placement) = match &usage.target { @@ -430,6 +437,7 @@ impl App { let current_surface = match surface { Surface::Saved(id) => { self.active_diagram == Some(id) + && !self.saved_scenario_diagram(id) && matches!(self.page, Page::Diagram | Page::Behavior) } Surface::Process(owner) | Surface::Scenario(owner) => { diff --git a/crates/oa-desktop/src/behavior/state_canvas.rs b/crates/oa-desktop/src/behavior/state_canvas.rs index abc3750f..85a62736 100644 --- a/crates/oa-desktop/src/behavior/state_canvas.rs +++ b/crates/oa-desktop/src/behavior/state_canvas.rs @@ -1712,6 +1712,7 @@ mod tests { layer: Layer::Sa, projection: ProjectionKind::StateMachine { machine }, nodes: BTreeMap::new(), + layout: None, routes: vec![], view_options: Default::default(), }, diff --git a/crates/oa-desktop/src/behavior/state_edit.rs b/crates/oa-desktop/src/behavior/state_edit.rs index 9aae33cb..6ab39fc2 100644 --- a/crates/oa-desktop/src/behavior/state_edit.rs +++ b/crates/oa-desktop/src/behavior/state_edit.rs @@ -292,6 +292,7 @@ pub fn create_view(project: &Project, machine: Id) -> Result { layer: owner.layer(), projection: ProjectionKind::StateMachine { machine }, nodes: BTreeMap::new(), + layout: None, routes: vec![], view_options: Default::default(), }, diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index 4363d9b0..80b212e9 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -1535,7 +1535,8 @@ fn group_label(path: &str) -> Option<&'static str> { } fn placement_allowed(project: &Project, diagram: &Diagram, element: &Element, current: Id) -> bool { - (diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer()) + diagram.projection.scenario().is_none() + && (diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer()) && diagram .projection .state_machine() @@ -2683,6 +2684,7 @@ mod tests { description: String::new(), layer: Layer::Oa, projection: ProjectionKind::Overview, + layout: None, routes: Vec::new(), view_options: Default::default(), nodes: BTreeMap::new(), diff --git a/crates/oa-desktop/src/port_workflow.rs b/crates/oa-desktop/src/port_workflow.rs index 0dc20d5d..8eb1a6c8 100644 --- a/crates/oa-desktop/src/port_workflow.rs +++ b/crates/oa-desktop/src/port_workflow.rs @@ -813,6 +813,7 @@ mod tests { description: String::new(), layer: Layer::Sa, projection: ProjectionKind::Overview, + layout: None, routes: Vec::new(), view_options: Default::default(), nodes: Default::default(), diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 0ebc252a..26641799 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -389,11 +389,24 @@ fn modal_width<'a>( .into() } fn diagram(app: &App) -> View<'_, Message> { + if app + .active_diagram + .and_then(|id| app.project().diagrams.get(&id)) + .is_some_and(|d| d.projection.scenario().is_some()) + { + return heading( + "Saved Scenario view", + crate::app::SAVED_SCENARIO_UNAVAILABLE, + ); + } let mut diagrams = app .project() .diagrams .values() - .filter(|d| d.layer == app.layer || d.projection == ProjectionKind::Traceability) + .filter(|d| { + d.projection.scenario().is_none() + && (d.layer == app.layer || d.projection == ProjectionKind::Traceability) + }) .map(|d| Choice { id: d.id, label: d.name.clone(), diff --git a/crates/oa-desktop/src/workflow.rs b/crates/oa-desktop/src/workflow.rs index 3f9de83f..b318e98a 100644 --- a/crates/oa-desktop/src/workflow.rs +++ b/crates/oa-desktop/src/workflow.rs @@ -352,7 +352,8 @@ impl Transition { } fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool { - (diagram.layer == layer || diagram.projection == ProjectionKind::Traceability) + (diagram.projection.is_architecture() || diagram.projection.state_machine().is_some()) + && (diagram.layer == layer || diagram.projection == ProjectionKind::Traceability) && (diagram.projection.state_machine().is_none() || !targets.is_empty()) && targets.iter().all(|id| { p.elements.get(id).is_some_and(|e| { @@ -1972,3 +1973,37 @@ mod port_authoring_tests { } } } + +#[cfg(test)] +mod saved_scenario_destination_tests { + use super::*; + + #[test] + fn empty_transition_results_do_not_make_a_scenario_an_architecture_destination() { + let mut p = Project::empty(); + let owner = p + .add_element(Kind::Scenario, Layer::Oa, "Scenario", None) + .unwrap(); + let mut diagram = Diagram { + id: Id::new_v4(), + alias: "target_view".into(), + name: "Target".into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + layout: None, + }; + assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa)); + let machine = p + .add_element(Kind::StateMachine, Layer::Oa, "Machine", None) + .unwrap(); + diagram.projection = ProjectionKind::StateMachine { machine }; + assert!(result_view_compatible(&p, &diagram, &[machine], Layer::Oa)); + assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa)); + diagram.projection = ProjectionKind::Overview; + assert!(result_view_compatible(&p, &diagram, &[], Layer::Oa)); + } +} diff --git a/crates/oa-desktop/src/workflow/transition_tests.rs b/crates/oa-desktop/src/workflow/transition_tests.rs index b73c341d..d7843593 100644 --- a/crates/oa-desktop/src/workflow/transition_tests.rs +++ b/crates/oa-desktop/src/workflow/transition_tests.rs @@ -13,6 +13,7 @@ fn view(p: &mut Project, number: u128, projection: ProjectionKind) -> Id { projection, view_options: Default::default(), nodes: Default::default(), + layout: None, routes: vec![], }, ); diff --git a/examples/release1/model.rs b/examples/release1/model.rs index e7a3e98b..7e53642f 100644 --- a/examples/release1/model.rs +++ b/examples/release1/model.rs @@ -151,7 +151,7 @@ fn diagram( (node.id, node) }) .collect(); - p.diagrams.insert(diagram_id, Diagram { id: diagram_id, alias: alias.into(), name: name.into(), description: "Teaching view: positions and filters do not change ownership or exchange identities.".into(), layer, projection, view_options: ViewOptions::default(), nodes, routes: vec![] }); + p.diagrams.insert(diagram_id, Diagram { id: diagram_id, alias: alias.into(), name: name.into(), description: "Teaching view: positions and filters do not change ownership or exchange identities.".into(), layer, projection, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] }); diagram_id } diff --git a/harness/basic-editing-contract.rs b/harness/basic-editing-contract.rs index 17acfb73..87cef0e6 100644 --- a/harness/basic-editing-contract.rs +++ b/harness/basic-editing-contract.rs @@ -84,6 +84,7 @@ fn fixture() -> Project { description: String::new(), layer, projection, + layout: None, routes: vec![], view_options: Default::default(), nodes: BTreeMap::new(), diff --git a/harness/behavior-creation-contract.rs b/harness/behavior-creation-contract.rs index 773c6979..98dd4d8a 100644 --- a/harness/behavior-creation-contract.rs +++ b/harness/behavior-creation-contract.rs @@ -315,6 +315,7 @@ fn predict(base: &CompiledSourceProject, stage: Stage, carrier: &Project) -> Com description: String::new(), layer: Layer::Sa, projection: ProjectionKind::StateMachine { machine }, + layout: None, routes: vec![], view_options: Default::default(), nodes: BTreeMap::from([( @@ -401,6 +402,7 @@ fn false_carrier(base: &Project) -> Project { projection: ProjectionKind::StateMachine { machine: test_id(3), }, + layout: None, routes: vec![], view_options: Default::default(), nodes: BTreeMap::from([( diff --git a/harness/child-work-native-contract.rs b/harness/child-work-native-contract.rs index 7ae0d51d..0f75791a 100644 --- a/harness/child-work-native-contract.rs +++ b/harness/child-work-native-contract.rs @@ -23,7 +23,7 @@ fn fixture(layer: Layer) -> Project { let key=id(50+index as u128); p.artifact_links.insert(key, ArtifactLink { id:key, alias:format!("evidence_{index}"), name:format!("Responsibility evidence {index}"), description:"Keep owner identity and evidence metadata".into(), owner, relation:ArtifactRelation::References, uri:format!("https://example.invalid/evidence/{index}"), revision:Some("review-7".into()), fragment:Some("assignment".into()) }); } - p.diagrams.insert(id(2), Diagram { id:id(2), alias:"allocation_view".into(), name:format!("{layer:?} allocated work"), description:"Native child-work fixture".into(), layer, projection:ProjectionKind::Allocation, routes:vec![], view_options:Default::default(), nodes:BTreeMap::new() }); + p.diagrams.insert(id(2), Diagram { id:id(2), alias:"allocation_view".into(), name:format!("{layer:?} allocated work"), description:"Native child-work fixture".into(), layer, projection:ProjectionKind::Allocation, layout:None, routes:vec![], view_options:Default::default(), nodes:BTreeMap::new() }); for (n,element,x,y,collapsed) in [(40,id(10),60.,60.,false),(41,id(11),84.,124.,true)] { p.diagrams.get_mut(&id(2)).unwrap().nodes.insert(id(n), NodePlacement { id:id(n), alias:format!("seed_view_{n}"), element, position:Position{x,y}, size:Size{width:224.,height:94.}, collapsed, port_anchors:vec![] }); } diff --git a/harness/experiments/scenario-movement-workflows-20260913/README.md b/harness/experiments/scenario-movement-workflows-20260913/README.md new file mode 100644 index 00000000..bda28b71 --- /dev/null +++ b/harness/experiments/scenario-movement-workflows-20260913/README.md @@ -0,0 +1,39 @@ +# Scenario movement: source evidence and discriminating workflows + +This extends the earlier [anchor-plan experiment](../behavior-anchor-plan-20260912/README.md) and the [layout contract candidate](../../scenario-layout-contract-candidate.md). It informs the user's #20 request for ordinary diagram manipulation and the application specification's requirement to learn from Capella. It does not select a final gesture policy or claim native Capella evaluation. + +## Verified primary-source behavior + +Capella7.1.0, commit3153b930a4284f6c82952658f1e8d8d6d8c8f340, connects movement of messages, Create/Delete, returns and executions to DoReorder. Its service moves sending and receiving interaction fragments separately, then moves the existing message in ownedMessages, retaining identities. See [tool mapping](https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/core/plugins/org.polarsys.capella.core.sirius.analysis/description/common.odesign#L15777) and [implementation](https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/core/plugins/org.polarsys.capella.core.sirius.analysis/src/org/polarsys/capella/core/sirius/analysis/SequenceDiagramServices.java#L257). + +Sirius documents separate blank-space insertion/removal using Ctrl-drag on the vertical ruler or Ctrl+Shift-drag on the canvas. Arrange All preserves order while reducing spacing. Create moves a lifeline's birth/header and Delete its death/bottom. The [user guide](https://eclipse.dev/sirius/doc/user/sequences/Sequence%20Diagrams.html) describes these operations. Sirius7.6.1, commit0a7288dda7c0069659b54091ba811abab0d247c2, [spacing assertions](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/SequenceSpaceTest.java#L75) demonstrate that inserting space within an execution leaves the call fixed but moves the return and extends the lifeline; inserting below leaves both fixed. + +Sirius [destruction movement tests](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/SequenceDestroyMessageMoveTest.java#L61) permit ordinary drag from y400 to230 across an unrelated message at300, preserving that message's coordinates, but reject death before earlier use of the receiver. Crossing is constrained by affected lifelines, not universally forbidden. + +Call/execution/return movement can be coupled, including self calls. Sirius has distinct predecessor values for starting and finishing event ends ([reorder tool contract](https://eclipse.dev/sirius/doc/specifier/sequences/Sequence%20Diagrams.html#EventReorderTool)); [synchronous call assertions](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/SyncCallMoveTest.java#L330) check coordinated movement and birth/death limits. + +Messages and executions must keep their endpoints within the same operand. [Frame coverage tests](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/CombinedFragmentsMoveTests.java#L103) reject enclosing a fragment within one covering fewer required participants. [Whole-frame movement assertions](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/SyncCallInOperandReorderTest.java#L75) translate enclosed calls and can enlarge a containing execution. This does not establish unrestricted semantic reparenting. + +## Interpretation and alternatives still to evaluate + +The evidence rules out assuming every sequence drag is cosmetic. It does not justify copying Capella event-end reorder directly: ARCADIA Studio currently orders complete occurrences in Scenario/Branch sequences, and validates reply dominance, lifetime and operand relations at that granularity. Independent Capella event-end ordering is a material semantic difference. + +Candidate A uses direct dragging for presentation spacing and a separately discoverable semantic reorder action. It reduces accidental semantic changes but may make the common order-correction task less discoverable. + +Candidate B uses one drag with explicit spacing or semantic-reorder preview according to the prospective position, preflighted against actual chronology/operand constraints. It may better support direct manipulation but must make a semantic transition clear before commit and prevent accidental changes near a boundary. No coordinate-only finite check authorizes that semantic operation. + +Both candidates need actual geometry feasibility, clear rejected-move feedback, cancellation, one transaction for model and presentation, exact Undo and saved/reopened geometry. Existing floor-clamp counterexamples remain rejected; silently snapping to a different chronology does not satisfy an exact authored coordinate request. + +| Engineer workflow | Observable discriminator | +| --- | --- | +| Make room for a commissioning review note without changing behavior | Predictable spacing; no unintended semantic order mutation near another row | +| Correct vent-then-isolate to isolate-then-vent within an operand | Discoverable reorder, comprehensible semantic preview, one coherent Undo | +| Extend service duration around a self call | Coupled reply/activation effects visible before acceptance; start and duration operations distinguishable | +| Move shutdown earlier across unrelated telemetry | Valid crossing allowed; death before last receiver use rejected clearly; exact lifecycle anchors retained | +| Adjust a fault-handling message near an alt divider, then move its frame | No accidental operand change; whole-frame translation distinguishable from semantic reparenting | + +Record task completion, unintended semantic mutations, preview comprehension, cancellation, Undo and reopen behavior. Pointer distance alone is not an adequate evaluator. The user request includes resizing and ordinary controls beyond this initial sparse lane/message storage slice. + +## Evidence limits + +This was source inspection, not executed native Capella/Sirius validation. Exact modifiers, feedback and persistence/Undo behavior have not been observed locally. Capella and Sirius revisions were inspected separately; their precise bundled dependency relationship was not established. Findings follow assertions rather than misleading test names. Real GUI acceptance remains required for the chosen product behavior. diff --git a/harness/fixtures/release1-native/communication-means/generate-communication-means.rs b/harness/fixtures/release1-native/communication-means/generate-communication-means.rs index 21ae9ca2..e5e22cec 100644 --- a/harness/fixtures/release1-native/communication-means/generate-communication-means.rs +++ b/harness/fixtures/release1-native/communication-means/generate-communication-means.rs @@ -104,6 +104,7 @@ fn diagram(p: &mut Project, n: u32, layer: Layer, name: &str, rows: &[(Id, f32, projection: ProjectionKind::Overview, view_options: ViewOptions::default(), nodes, + layout: None, routes: vec![], }, ); diff --git a/harness/staged-source-authoring-contract.rs b/harness/staged-source-authoring-contract.rs index f2ad9e49..c1b71c73 100644 --- a/harness/staged-source-authoring-contract.rs +++ b/harness/staged-source-authoring-contract.rs @@ -298,6 +298,7 @@ fn stage_commands(before: &Project, stage: Stage, roles: &mut Roles<'_>) -> Vec< description: String::new(), layer: Layer::Sa, projection: ProjectionKind::StateMachine { machine }, + layout: None, routes: vec![], view_options: Default::default(), nodes: BTreeMap::from([( @@ -422,6 +423,7 @@ fn false_carrier(base: &Project) -> Project { projection: ProjectionKind::StateMachine { machine: test_id(6), }, + layout: None, routes: vec![], view_options: Default::default(), nodes: BTreeMap::from([(