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/lib.rs b/crates/oa-core/src/lib.rs index 43615cc9..940deda6 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod ports; pub mod projection; pub mod references; pub mod routing; +pub mod scenario_layout; pub mod semantic; pub mod session; mod source_defaults; @@ -20,6 +21,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..5cb61cdd --- /dev/null +++ b/crates/oa-core/src/scenario_layout/tests.rs @@ -0,0 +1,800 @@ +use super::*; +use crate::{ + Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind, + ScenarioViewpoint, +}; +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 Some(SharedBody::Scenario { viewpoint, .. }) = + project.elements.get_mut(&owner).unwrap().shared_mut() + else { + panic!("Scenario fixture body"); + }; + *viewpoint = ScenarioViewpoint::ParticipantInteraction; + 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 Some(SharedBody::Scenario { viewpoint, .. }) = + f.project.elements.get_mut(&called).unwrap().shared_mut() + else { + panic!("Called Scenario fixture body"); + }; + *viewpoint = ScenarioViewpoint::ParticipantInteraction; + 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..36f8a8d3 --- /dev/null +++ b/crates/oa-core/src/scenario_layout/tests/source_lifecycle.rs @@ -0,0 +1,302 @@ +//! 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")); +} + +#[test] +fn compact_defaults_and_scenario_row_identity_compose_through_rename_and_clear() { + let f = fixture(); + let initial = authored(&f); + let mut bundle = initial.bundle.clone(); + let location = &initial.index.declarations[&f.performer]; + bundle.files.get_mut(&location.path).unwrap().insert_str( + location.span.end - 1, + "\n description // explicitly default prose\n \"\";\n", + ); + let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + let mut expected = original.decoded.project.clone(); + let performer = expected.elements.get_mut(&f.performer).unwrap(); + performer.name = "Reviewed controller".into(); + performer.alias = "reviewed_controller".into(); + expected.elements.get_mut(&f.lane).unwrap().alias = "reviewed_lane".into(); + let rows = payload(expected.diagrams.get_mut(&f.view).unwrap()); + rows.lanes.swap(0, 1); + rows.messages.swap(0, 1); + rows.messages[0].send_y = 390.; + let updated = update_bundle(&original, &expected, ProjectLimits::default()).unwrap(); + assert_eq!(updated.decoded.project, expected); + let element = &updated.index.declarations[&f.performer]; + let text = &updated.bundle.files[&element.path][element.span.start..element.span.end]; + assert!( + text.contains("description // explicitly default prose\n \"\";"), + "{text}" + ); + assert!( + !text.contains("categories"), + "an omitted default must stay omitted: {text}" + ); + let diagram = &updated.index.declarations[&f.view]; + let text = &updated.bundle.files[&diagram.path][diagram.span.start..diagram.span.end]; + assert!( + text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap() + ); + assert!(text.contains("reviewed_lane; x 4.20e2;"), "{text}"); + assert!(text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap()); + assert!(text.contains("send_y 2.00e2"), "{text}"); + assert!(!text.contains("view_options"), "{text}"); + expected.diagrams.get_mut(&f.view).unwrap().layout = None; + let cleared = update_bundle(&updated, &expected, ProjectLimits::default()).unwrap(); + assert_eq!(cleared.decoded.project, expected); + let declaration = &cleared.index.declarations[&f.view]; + let text = + &cleared.bundle.files[&declaration.path][declaration.span.start..declaration.span.end]; + assert!(!text.contains("layout"), "{text}"); + assert!(text.contains("// Controller spacing rationale."), "{text}"); + assert_eq!( + update_bundle(&cleared, &expected, ProjectLimits::default()) + .unwrap() + .bundle, + cleared.bundle, + ); +} 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 22575d07..aa713f67 100644 --- a/crates/oa-core/src/source_project.rs +++ b/crates/oa-core/src/source_project.rs @@ -972,7 +972,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 { @@ -1224,6 +1235,29 @@ impl<'a> InsertionSyntax<'a> { } } +// 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, @@ -1301,6 +1335,7 @@ impl SyntaxUpdate<'_> { actual: &language::EditValue, before: Option<&language::EditValue>, after: &language::EditValue, + context: EditContext, edits: &mut LocalEdits, ) { use language::EditKind; @@ -1316,7 +1351,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| { @@ -1326,7 +1361,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]) @@ -1379,6 +1414,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. @@ -1402,7 +1438,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; } } @@ -1414,9 +1456,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); } } @@ -1470,6 +1519,7 @@ impl SyntaxUpdate<'_> { actual: &[language::EditValue], before: Option<&[language::EditValue]>, after: &[language::EditValue], + context: EditContext, edits: &mut LocalEdits, ) { let previous = before.unwrap_or(&[]); @@ -1482,6 +1532,7 @@ impl SyntaxUpdate<'_> { self.old_aliases, self.legacy_value_paths, None, + context, ) }) .collect(); @@ -1494,6 +1545,7 @@ impl SyntaxUpdate<'_> { self.new_aliases, self.legacy_value_paths, self.identity_origins, + context, ) }) .collect(); @@ -1522,7 +1574,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; } @@ -1555,7 +1607,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 { @@ -1594,14 +1652,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/source_project/source_migration.rs b/crates/oa-core/src/source_project/source_migration.rs index 54d50df9..fb60c08c 100644 --- a/crates/oa-core/src/source_project/source_migration.rs +++ b/crates/oa-core/src/source_project/source_migration.rs @@ -72,6 +72,11 @@ pub(super) fn upgrade( &actual.fields, Some(&actual.fields), &next.fields, + if project.diagrams.contains_key(&id) { + EditContext::Diagram + } else { + EditContext::Other + }, edits.entry(path.clone()).or_default(), ); } 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/default_omission_preservation.rs b/crates/oa-core/tests/default_omission_preservation.rs index bd148c79..0fa234c0 100644 --- a/crates/oa-core/tests/default_omission_preservation.rs +++ b/crates/oa-core/tests/default_omission_preservation.rs @@ -231,6 +231,7 @@ fn node_project() -> Project { }, )]), routes: vec![], + layout: None, }, ); expected 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 107caacd..84ee81d8 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -19,6 +19,10 @@ mod reference_authoring; #[cfg(test)] mod review_navigation_tests; #[cfg(test)] +mod scenario_details_tests; +mod scenario_diagram; +mod scenario_spacing; +#[cfg(test)] mod source_indentation_tests; pub(crate) mod source_rename; mod source_symbols; @@ -27,6 +31,7 @@ pub(crate) mod text_workspace; mod transition_context_tests; #[cfg(test)] mod view_copy_tests; +pub(crate) const SAVED_SCENARIO_UNAVAILABLE: &str = "This action uses architecture diagram tools and is unavailable for a saved Scenario. Open the view in Behavior; saved positions are preserved."; use crate::forms::Draft; use crate::{ behavior, @@ -546,6 +551,7 @@ pub struct App { pub(crate) diagram_navigation_focus: Option, source_diagram_job: Option, diagram_lookup_in_flight: Option, + pending_scenario_reveal_visit: Option, pending_diagram_reveal: Option<( crate::diagram_usage::Context, crate::diagram_usage::Usage, @@ -594,7 +600,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); @@ -670,6 +676,7 @@ impl App { source_diagram_job: None, diagram_lookup_in_flight: None, pending_diagram_reveal: None, + pending_scenario_reveal_visit: None, model_issues, migration: None, source_version: 0, @@ -684,6 +691,7 @@ impl App { workflow: None, }; app.route_state_diagram_workspace(); + app.reconcile_scenario_workspace(); app.last_workspace_page = app.page; app } @@ -819,6 +827,17 @@ impl App { self.sync_with_alias_editor(None); } fn sync_with_alias_editor(&mut self, hint: Option>) { + // A saved view may be retargeted across layers by one source transaction. + // Follow that explicit identity before generic active-view normalization. + if let Some(rep @ behavior::scenario_layout::Representation::Saved { diagram }) = + self.behavior.scenario.representation() + && self.active_diagram == Some(diagram) + && let Some(owner) = + behavior::scenario_layout::presentation::checked_owner(self.project(), rep) + { + self.layer = self.project().elements[&owner].layer(); + self.behavior.active_owner = Some(owner); + } self.explorer_index = crate::explorer::Index::new(self.project(), self.layer); self.model_issues = validate(self.project()); self.normalize_creation(); @@ -843,7 +862,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); } @@ -923,6 +942,26 @@ impl App { .map_err(|e| e.to_string()) }) { Ok(id) => { + if self.behavior.scenario.representation() + == Some(behavior::scenario_layout::Representation::Saved { diagram: original }) + && id != original + { + let representation = + behavior::scenario_layout::Representation::Saved { diagram: id }; + if let Some(owner) = behavior::scenario_layout::presentation::checked_owner( + self.project(), + representation, + ) { + self.behavior.scenario.enter_representation( + self.canvas_document, + owner, + representation, + ); + if self.page != Page::Behavior { + self.behavior.scenario.leave(); + } + } + } if self.active_diagram == Some(original) { self.active_diagram = Some(id); } @@ -1097,7 +1136,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)); @@ -1129,6 +1168,17 @@ impl App { && let Some(owner) = crate::behavior::selected_behavior(self.project(), id) { self.behavior.active_owner = Some(owner); + if self.behavior.scenario.representation().is_some_and(|rep| { + behavior::scenario_layout::presentation::checked_owner(self.project(), rep) + != Some(owner) + }) && self.project().elements[&owner].kind() == Kind::Scenario + { + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Derived { owner }, + ); + } else if self.project().elements[&owner].kind() != Kind::Scenario { + self.behavior.scenario.forget_view(); + } } if self.reset_state_view_after_navigation(previous_state_view) && self.page == Page::Behavior @@ -1309,6 +1359,9 @@ impl App { { return Task::none(); } + if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Spacing(event)) = &message { + return self.scenario_spacing(*event); + } // Reject stale rename intents before camera/reveal reconciliation or // source interaction can disturb a newer navigation destination. if let Message::SourceRename(event) = &message @@ -1321,6 +1374,42 @@ impl App { if let Message::SourceRename(event @ source_rename::Event::PreviewScrolled(..)) = message { return self.source_rename_event(event); } + // Prior view visits are inert before they can retire a newer reveal, + // camera lifetime or focus operation. Passive events may relax epoch, + // but never the destination identity. + if let Message::ScenarioCanvas(event) = &message { + let captured = event.context(); + if self.scenario_context().is_none_or(|current| { + current.document != captured.document + || current.scenario != captured.scenario + || current.representation != captured.representation + || current.visit != captured.visit + || (!matches!(event, behavior::scenario::CanvasEvent::Restored(..)) + && current.revision != captured.revision) + }) { + return Task::none(); + } + if let behavior::scenario::CanvasEvent::Camera(_, lifetime, _) = event + && *lifetime != self.camera_epoch + { + return Task::none(); + } + // A retained Details button may belong to an earlier selection or + // canvas epoch. Refuse it before retiring any newer reveal or draft. + if let behavior::scenario::CanvasEvent::Properties(_, id) = event + && (self.scenario_context() != Some(captured) + || self.selected != *id + || !self.project().contains(*id) + || !behavior::scenario::properties_target(self.project(), captured, *id) + || self.behavior.has_pending() + || self.draft_dirty() + || self.source_dirty() + || self.source_rename.open() + || !behavior::canvas_workspace::keyboard_enabled(self)) + { + return Task::none(); + } + } let usage_viewport = self.diagram_reveal_viewport(&message); let window_resized = matches!(&message, Message::WindowResized(_)); // Reject stale measurements before legacy viewport handlers can change @@ -1346,6 +1435,7 @@ impl App { | Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..)) ); let before = self.camera_boundary(); + let spacing_selected = self.selected; let task = self.update_inner(message); if self.allocation_boundary() != allocation_before { self.allocation_retire(); @@ -1363,6 +1453,13 @@ impl App { { self.diagram_navigation_focus = None; } + if (self.camera_boundary() != before + || self.selected != spacing_selected + || !self.spacing_enabled()) + && self.behavior.scenario.cancel_spacing() + { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } if !camera && self.camera_boundary() != before { self.camera_epoch = self.camera_epoch.wrapping_add(1); self.camera_boundary_epoch = self.canvas_epoch; @@ -1381,6 +1478,7 @@ impl App { let valid_scenario = |c: behavior::scenario::Context| { c.document == scope.document && c.scenario == scope.owner + && scope.scenario_view == Some((c.representation, c.visit)) && c.revision == scope.revision && self.camera_toolbar_epoch(c.epoch) }; @@ -1408,6 +1506,9 @@ impl App { _ => return Task::none(), } let task = self.update(message); + if self.behavior.scenario.cancel_spacing() { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } // Reapplying Fit or a tool also ends a held gesture. Focus is stamped // after the action, for Iced's final-view ordering. if self.camera_epoch == scope.epoch { @@ -1773,6 +1874,7 @@ impl App { self.refresh_source_rename_context(); self.reconcile_reference_chooser(); self.route_state_diagram_workspace(); + self.reconcile_scenario_workspace(); if self.page != Page::Home { self.last_workspace_page = self.page; } @@ -1781,11 +1883,6 @@ impl App { .topology .enter(context.document, context.owner); } - if let Some(context) = self.scenario_context() { - self.behavior - .scenario - .enter(context.document, context.scenario); - } if self.connection.as_ref().is_some_and(|session| { Some(session.context) != self.route_context() || self.page != Page::Diagram @@ -1829,15 +1926,23 @@ impl App { } } pub(crate) fn scenario_context(&self) -> Option { - let scenario = crate::behavior::selected_owner(self)?; - (self.page == Page::Behavior - && self.project().elements.get(&scenario)?.kind() == Kind::Scenario) - .then_some(crate::behavior::scenario::Context { - document: self.canvas_document, - scenario, - revision: self.document.revision(), - epoch: self.canvas_epoch, - }) + if self.page != Page::Behavior { + return None; + } + let scenario = if matches!( + self.behavior.scenario.representation(), + Some(behavior::scenario_layout::Representation::Saved { .. }) + ) { + self.behavior.scenario.owner()? + } else { + crate::behavior::selected_owner(self)? + }; + self.behavior.scenario.context( + self.canvas_document, + scenario, + self.document.revision(), + self.canvas_epoch, + ) } pub(crate) fn scenario_focus_owner(&self) -> Option { if self.busy @@ -1853,7 +1958,46 @@ 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::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::WorkDrag(event) => return self.allocation_event(event), Message::BehaviorControl(intent) => return self.behavior_control(intent), @@ -1876,6 +2020,8 @@ impl App { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario + && current.representation == context.representation + && current.visit == context.visit && current.revision == context.revision }) && self.scenario_focus_owner().is_some() && self.behavior.scenario.take_form_focus(mount) @@ -1888,6 +2034,8 @@ impl App { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario + && current.representation == context.representation + && current.visit == context.visit && current.revision == context.revision }) && self.behavior.scenario.viewport(size) { @@ -1897,7 +2045,10 @@ impl App { } if let CanvasEvent::Restored(_, token, area, offset) = event { if self.scenario_context().is_some_and(|current| { - current.document == context.document && current.scenario == context.scenario + current.document == context.document + && current.scenario == context.scenario + && current.representation == context.representation + && current.visit == context.visit }) && self .behavior .scenario @@ -1914,6 +2065,8 @@ impl App { if self.scenario_context().is_some_and(|current| { current.document == context.document && current.scenario == context.scenario + && current.representation == context.representation + && current.visit == context.visit && current.revision == context.revision }) && self.behavior.scenario.scrolled(area, offset) { @@ -1927,6 +2080,8 @@ impl App { || !self.scenario_context().is_some_and(|c| { c.document == context.document && c.scenario == context.scenario + && c.representation == context.representation + && c.visit == context.visit && c.revision == context.revision }) { @@ -1935,6 +2090,7 @@ impl App { let scope = canvas_workspace::Scope { document: context.document, owner: context.scenario, + scenario_view: Some((context.representation, context.visit)), revision: context.revision, epoch: lifetime, }; @@ -1945,6 +2101,9 @@ impl App { { return Task::none(); } + if self.behavior.scenario.cancel_spacing() { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } match input { Event::Traverse(_) | Event::ControlFocus(_) @@ -1956,7 +2115,10 @@ impl App { self.canvas_epoch = self.canvas_epoch.wrapping_add(1) } input => { - if self.behavior.scenario.camera(input) { + let mut state = std::mem::take(&mut self.behavior.scenario); + let changed = state.camera(self.project(), context.scenario, input); + self.behavior.scenario = state; + if changed { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } } @@ -1981,10 +2143,56 @@ impl App { self.begin_model_delete(None); } } + CanvasEvent::Representation(_, requested) => { + use behavior::scenario_layout::{ + Representation, presentation::checked_owner, + }; + if checked_owner(self.project(), requested) != Some(context.scenario) { + return Task::none(); + } + // An explicitly selected current view record follows + // the arrangement picker, as with the Diagram picker. + // Independent occurrence/lifeline selection stays put. + let follow_view = matches!(context.representation, + Representation::Saved { diagram } if self.selected == diagram); + if !self.apply() { + return Task::none(); + } + let requested = match requested { + Representation::Saved { diagram } + if context.representation + == (Representation::Saved { diagram }) => + { + Representation::Saved { + diagram: self.active_diagram.unwrap_or(diagram), + } + } + Representation::Derived { owner } => Representation::Derived { + owner: self.behavior.active_owner.unwrap_or(owner), + }, + other => other, + }; + if self.enter_scenario_representation(requested) && follow_view { + self.selected = match requested { + Representation::Saved { diagram } => diagram, + // Automatic has no Diagram record; its Scenario + // owner is the corresponding inspectable target. + Representation::Derived { owner } => owner, + }; + self.refresh_draft(); + } + } CanvasEvent::Shelf(_, shelf) => { self.behavior.scenario.toggle_shelf(shelf); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } + CanvasEvent::Properties(_, _) => { + // The checked action opens the current record only: no + // select()/Apply side effect or representation change. + self.behavior.scenario.show_properties(); + self.inspector_tab = crate::navigation::InspectorTab::Properties; + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } CanvasEvent::Zoom(_, scale) => { let mut state = std::mem::take(&mut self.behavior.scenario); if state.camera_zoom(self.project(), context.scenario, scale) { @@ -2005,7 +2213,11 @@ impl App { Ok(target) => { // A command can preselect this target while the // caller remains visible, bypassing select below. - self.behavior.active_owner = Some(target); + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Derived { + owner: target, + }, + ); self.select(target); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } @@ -2027,7 +2239,7 @@ impl App { // A command may already have selected this UUID while // leaving the referenced definition open. select's // same-UUID fast path must not keep that old owner. - self.behavior.active_owner = Some(caller.owner); + self.enter_scenario_representation(caller.representation); self.select(caller.occurrence); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); if let Some(context) = self.scenario_context() { @@ -2055,6 +2267,15 @@ impl App { } } CanvasEvent::Choose(_, tool) => { + if tool != scenario::CanvasTool::Select + && !self + .behavior + .scenario + .coordinate_input(self.project(), context) + { + self.status = "Canvas insertion and reordering are unavailable for this presentation.".into(); + return Task::none(); + } if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { self.status = @@ -2066,6 +2287,17 @@ impl App { self.canvas_epoch = self.canvas_epoch.wrapping_add(1); } mutation => { + if !behavior::scenario_layout::presentation::present( + self.project(), + context.representation, + self.selected, + ) + .capabilities() + .coordinate_input + { + self.status = "Layout editing is unavailable for this presentation. Saved positions are preserved.".into(); + return Task::none(); + } if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() { self.status = @@ -2166,6 +2398,13 @@ impl App { }) { self.behavior.active_owner = Some(choice.id); + if self.project().elements[&choice.id].kind() == Kind::Scenario { + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Derived { owner: choice.id }, + ); + } else { + self.behavior.scenario.forget_view(); + } self.behavior.diagram = Default::default(); self.canvas_epoch = self.canvas_epoch.wrapping_add(1); self.select(choice.id); @@ -2514,7 +2753,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. @@ -2523,9 +2762,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(); @@ -2536,6 +2773,15 @@ impl App { crate::behavior::selected_behavior(self.project(), self.selected) { self.behavior.active_owner = Some(owner); + if self.project().elements[&owner].kind() == Kind::Scenario + && self.behavior.scenario.owner() != Some(owner) + { + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Derived { owner }, + ); + } else if self.project().elements[&owner].kind() != Kind::Scenario { + self.behavior.scenario.forget_view(); + } } self.behavior.active_owner = crate::behavior::selected_owner(self); self.reset_state_view_after_navigation(previous_state_view); @@ -2772,6 +3018,7 @@ impl App { } Message::Layer(layer) => { if self.apply() { + self.behavior.scenario.forget_view(); let previous_state_view = self.state_view_identity(); self.layer = layer; self.create_kind = if layer == Layer::Oa { @@ -2783,7 +3030,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; @@ -2944,6 +3191,9 @@ impl App { } } Message::RevealInDiagram(diagram, selected) => { + if self.saved_scenario_diagram(diagram) { + return self.reveal_saved_scenario(diagram, selected); + } let original = self.selected; if !self.apply() { return Task::none(); @@ -2956,6 +3206,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() @@ -3023,6 +3277,7 @@ impl App { description: String::new(), layer: self.layer, projection: ProjectionKind::Allocation, + layout: None, routes: Vec::new(), view_options: Default::default(), nodes: Default::default(), @@ -3176,6 +3431,16 @@ impl App { self.status = "Diagram no longer exists.".into(); return Task::none(); } + if self.saved_scenario_diagram(choice.id) + && behavior::scenario_layout::presentation::checked_owner( + self.project(), + behavior::scenario_layout::Representation::Saved { diagram: choice.id }, + ) + .is_none() + { + self.status = "This Scenario view has an incompatible or missing owner.".into(); + return Task::none(); + } let previous_diagram = self.active_diagram; let follow_view = previous_diagram == Some(self.selected); if self.apply() { @@ -3195,7 +3460,14 @@ impl App { } self.selected_node = None; self.refresh_draft(); - self.fit(); + if self.saved_scenario_diagram(diagram) { + self.enter_scenario_representation( + behavior::scenario_layout::Representation::Saved { diagram }, + ); + } else { + self.page = Page::Diagram; + self.fit(); + } } } Message::Projection(projection) => { @@ -3205,6 +3477,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(); @@ -3789,6 +4065,14 @@ impl App { } } Message::Export => { + let Some(diagram) = self.active_diagram else { + self.status = "Create a diagram first.".into(); + return Task::none(); + }; + if !self.project().diagrams.contains_key(&diagram) { + self.status = crate::diagram::ExportError::MissingDiagram(diagram).to_string(); + return Task::none(); + } if self.apply() { return self.choose_file(FileOperation::Export); } @@ -3851,7 +4135,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) { @@ -3881,6 +4167,7 @@ impl App { description: String::new(), layer: self.layer, projection: preferred, + layout: None, routes: Vec::new(), view_options: Default::default(), nodes: Default::default(), @@ -4180,11 +4467,24 @@ 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(); }; - let svg = crate::diagram::export_svg(self.project(), diagram); + let svg = match crate::diagram::export_svg(self.project(), diagram) { + Ok(svg) => svg, + Err(error) => { + self.status = error.to_string(); + return Task::none(); + } + }; self.busy = true; Task::perform( async move { @@ -4423,6 +4723,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?, @@ -4647,6 +4950,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 @@ -4694,7 +5003,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); ( @@ -4708,7 +5017,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 @@ -5789,6 +6098,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(), @@ -5955,3 +6265,240 @@ 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 rejected_export_schedules_no_write_and_preserves_draft_history_and_busy_state() { + let (mut app, _, saved, _) = fixture(); + let _ = app.update(Message::Field("/name".into(), "Keep export draft".into())); + assert!(app.draft_dirty()); + let path = + std::env::temp_dir().join(format!("arcadia-rejected-export-{}.svg", Id::new_v4())); + std::fs::write(&path, b"existing export").unwrap(); + for target in [Some(saved), Some(Id::new_v4()), None] { + for busy in [false, true] { + app.active_diagram = target; + app.busy = busy; + let before = app.document.bundle().clone(); + let history = ( + app.document.revision(), + app.document.can_undo(), + app.document.can_redo(), + ); + let view = ( + app.page, + app.selected, + app.active_diagram, + app.zoom, + app.pan, + ); + let task = app.export_to(path.clone()); + assert_eq!(task.units(), 0, "rejection must not schedule a write"); + assert_eq!(app.busy, busy); + assert_eq!(app.document.bundle(), &before); + assert_eq!( + ( + app.document.revision(), + app.document.can_undo(), + app.document.can_redo() + ), + history + ); + assert_eq!( + ( + app.page, + app.selected, + app.active_diagram, + app.zoom, + app.pan + ), + view + ); + assert!(app.draft_dirty()); + assert!(app.file_prompt.is_none()); + assert_eq!(std::fs::read(&path).unwrap(), b"existing export"); + } + } + std::fs::remove_file(path).unwrap(); + } + + #[test] + fn missing_export_target_does_not_apply_pending_properties_or_open_dialog() { + for target in [None, Some(Id::new_v4())] { + let (mut app, _, _, _) = fixture(); + let _ = app.update(Message::Field( + "/name".into(), + "Unapplied export draft".into(), + )); + app.active_diagram = target; + let before = app.document.bundle().clone(); + let revision = app.document.revision(); + let task = app.handle(Message::Export); + assert_eq!(task.units(), 0); + assert_eq!(app.document.bundle(), &before); + assert_eq!(app.document.revision(), revision); + assert!(app.draft_dirty()); + assert!(app.file_prompt.is_none()); + assert!(!app.busy); + } + } + + #[test] + fn saved_picker_with_invalid_properties_and_reveal_with_pending_properties_do_not_apply() { + for reveal in [false, true] { + let (mut app, architecture, saved, owner) = fixture(); + let _ = app.update(Message::Field( + if reveal { "/name" } else { "/id" }.into(), + if reveal { + "Unapplied scenario name" + } else { + "-" + } + .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()); + } + } + + #[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.rs b/crates/oa-desktop/src/app/diagram_reveal.rs index f0836867..1bdca84c 100644 --- a/crates/oa-desktop/src/app/diagram_reveal.rs +++ b/crates/oa-desktop/src/app/diagram_reveal.rs @@ -1,5 +1,6 @@ //! Complete a navigation camera after the destination reports its real viewport. use super::*; +use crate::behavior::scenario_layout::Representation; use crate::diagram_usage::{Context, Surface}; #[cfg(test)] mod tests; @@ -47,11 +48,23 @@ impl App { && c.epoch == *epoch, ), Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => ( - Surface::Scenario(c.scenario), + match c.representation { + Representation::Derived { owner } => Surface::Scenario(owner), + Representation::Saved { diagram } => Surface::Saved(diagram), + }, *size, c.document == context.document && c.revision == context.revision - && c.epoch == *epoch, + && c.epoch == *epoch + && self.pending_scenario_reveal_visit == Some(c.visit) + && self.page == Page::Behavior + && self.behavior.active_owner == Some(c.scenario) + && self.behavior.scenario.context( + self.canvas_document, + c.scenario, + self.document.revision(), + self.canvas_epoch, + ) == Some(*c), ), Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => ( Surface::Saved(c.diagram), @@ -93,6 +106,7 @@ impl App { | Message::StateDiagram(behavior::state_edit::Event::Viewport(..)) ) { self.pending_diagram_reveal = None; + self.pending_scenario_reveal_visit = None; if !Self::is_navigation_camera(message) { self.diagram_navigation_focus = None; } @@ -142,5 +156,6 @@ impl App { self.reveal_diagram_usage(&usage); } self.pending_diagram_reveal = None; + self.pending_scenario_reveal_visit = None; } } diff --git a/crates/oa-desktop/src/app/diagram_reveal/tests.rs b/crates/oa-desktop/src/app/diagram_reveal/tests.rs index 2e27aa75..be51ab84 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(), }, @@ -143,6 +144,7 @@ fn measurement( epoch: u64, surface: Surface, size: Size, + visit: Id, ) -> Message { match canvas { Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size), @@ -159,6 +161,10 @@ fn measurement( behavior::scenario::Context { document: context.document, scenario: surface.id(), + representation: Representation::Derived { + owner: surface.id(), + }, + visit, revision: context.revision, epoch, }, @@ -176,12 +182,16 @@ fn measurement( } } -fn stamp(app: &App) -> (Context, u64) { +fn stamp(app: &App) -> (Context, u64, Id) { let (context, _, epoch) = app .pending_diagram_reveal .as_ref() .expect("reveal awaits measured destination"); - (*context, *epoch) + ( + *context, + *epoch, + app.pending_scenario_reveal_visit.unwrap_or(Id::nil()), + ) } fn assert_centered(app: &App, canvas: Canvas, target: &Usage) { @@ -219,7 +229,11 @@ fn assert_centered(app: &App, canvas: Canvas, target: &Usage) { expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select); expected.viewport(ACTUAL); expected.zoom(Some(zoom)); - expected.camera(behavior::canvas_workspace::Event::Pan(pan)); + expected.camera( + app.project(), + target.surface.id(), + behavior::canvas_workspace::Event::Pan(pan), + ); assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary()); } } @@ -238,10 +252,10 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author app.document.dirty(), ); assert!(app.reveal_diagram_usage(&target)); - let (context, epoch) = stamp(&app); + let (context, epoch, visit) = stamp(&app); send( &mut app, - measurement(canvas, context, epoch, target.surface, ACTUAL), + measurement(canvas, context, epoch, target.surface, ACTUAL, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert_centered(&app, canvas, &target); @@ -260,7 +274,7 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author let camera = app.camera_boundary(); send( &mut app, - measurement(canvas, context, epoch, target.surface, ACTUAL), + measurement(canvas, context, epoch, target.surface, ACTUAL, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert!(app.camera_boundary() == camera); @@ -274,7 +288,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() { for invalid in 0..6 { let (mut app, target) = fixture(canvas); assert!(app.reveal_diagram_usage(&target)); - let (context, epoch) = stamp(&app); + let (context, epoch, visit) = stamp(&app); let camera = app.camera_boundary(); let mut stale = context; let mut wrong_epoch = epoch; @@ -289,13 +303,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() { } send( &mut app, - measurement(canvas, stale, wrong_epoch, target.surface, size), + measurement(canvas, stale, wrong_epoch, target.surface, size, visit), ); assert!( app.pending_diagram_reveal.is_some(), "{canvas:?} invalid case {invalid}" ); - assert_eq!(stamp(&app), (context, epoch)); + assert_eq!(stamp(&app), (context, epoch, visit)); assert_eq!(app.canvas_epoch, epoch); assert!( app.camera_boundary() == camera, @@ -303,7 +317,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() { ); send( &mut app, - measurement(canvas, context, epoch, target.surface, ACTUAL), + measurement(canvas, context, epoch, target.surface, ACTUAL, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert_centered(&app, canvas, &target); @@ -317,9 +331,10 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte let (mut app, target) = fixture(canvas); let accepted = app.document.snapshot(); assert!(app.reveal_diagram_usage(&target)); - let (context, old_epoch) = stamp(&app); + let (context, old_epoch, visit) = stamp(&app); send(&mut app, Message::WindowResized(Size::new(1100., 700.))); - let (current, epoch) = stamp(&app); + let (current, epoch, current_visit) = stamp(&app); + assert_eq!(current_visit, visit); assert_eq!(current, context); assert_eq!(epoch, app.canvas_epoch); if matches!(canvas, Canvas::Process | Canvas::Scenario) { @@ -327,14 +342,14 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte let camera = app.camera_boundary(); send( &mut app, - measurement(canvas, context, old_epoch, target.surface, ACTUAL), + measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit), ); - assert_eq!(stamp(&app), (current, epoch)); + assert_eq!(stamp(&app), (current, epoch, visit)); assert!(app.camera_boundary() == camera); } send( &mut app, - measurement(canvas, current, epoch, target.surface, ACTUAL), + measurement(canvas, current, epoch, target.surface, ACTUAL, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert_centered(&app, canvas, &target); @@ -349,7 +364,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() let (mut app, target) = fixture(canvas); let accepted = app.document.snapshot(); assert!(app.reveal_diagram_usage(&target)); - let (context, epoch) = stamp(&app); + let (context, epoch, visit) = stamp(&app); let event = match action { 0 => match canvas { Canvas::Architecture => Message::Pan(Vector::new(31., -17.)), @@ -386,7 +401,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() // restore the old navigation camera after deliberate user input. send( &mut app, - measurement(canvas, context, epoch, target.surface, WIDE), + measurement(canvas, context, epoch, target.surface, WIDE, visit), ); assert!(app.pending_diagram_reveal.is_none()); assert!( diff --git a/crates/oa-desktop/src/app/diagram_usages.rs b/crates/oa-desktop/src/app/diagram_usages.rs index 5072a8c9..f9b45d12 100644 --- a/crates/oa-desktop/src/app/diagram_usages.rs +++ b/crates/oa-desktop/src/app/diagram_usages.rs @@ -1,5 +1,6 @@ //! Navigation-only adapter: validates destinations before changing presentation. use super::*; +use crate::behavior::scenario_layout::{Representation, presentation}; use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage}; #[cfg(test)] mod tests; @@ -181,6 +182,7 @@ impl App { // Only an accepted new chooser supersedes the previous reveal. // Rejected/stale panel envelopes leave its first measurement intact. self.pending_diagram_reveal = None; + self.pending_scenario_reveal_visit = None; self.diagram_navigation_focus = None; self.canvas_epoch = self.canvas_epoch.wrapping_add(1); return Task::batch([ @@ -291,11 +293,78 @@ impl App { Task::none() } + /// Explicit saved-Scenario reveal runs before generic Apply. An absent target + /// never selects the owner or another saved arrangement as a fallback. + pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task { + if self.diagram_usages.is_some() || !self.usage_navigation_available() { + self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into(); + return Task::none(); + } + if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none() + { + return Task::none(); + } + let Some(found) = self + .current_surface_usages(selected, Surface::Saved(diagram)) + .into_iter() + .find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_))) + else { + self.status = "This object has no visible target in that saved Scenario.".into(); + return Task::none(); + }; + self.reveal_diagram_usage(&found); + Task::none() + } + pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool { if !self.usage_navigation_available() { return false; } let previous_layer = self.layer; + let scenario_representation = match usage.surface { + Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => { + Some(Representation::Saved { diagram }) + } + Surface::Scenario(owner) => Some(Representation::Derived { owner }), + _ => None, + }; + if let Some(representation) = scenario_representation { + let Some(owner) = presentation::checked_owner(self.project(), representation) else { + return false; + }; + if !self + .behavior + .scenario + .representation_usages(self.project(), representation, usage.subject) + .iter() + .any(|row| row == usage) + { + return false; + } + // Finish target/camera admission before changing the App route. The + // checked immutable owner also makes the following entry infallible. + let revealed = match representation { + Representation::Derived { .. } => self.behavior.scenario.reveal_usage( + self.document.project(), + self.canvas_document, + owner, + &usage.target, + ), + Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage( + self.document.project(), + self.canvas_document, + owner, + representation, + &usage.target, + ), + }; + if !revealed { + return false; + } + if !self.enter_scenario_representation(representation) { + return false; + } + } let p = self.document.project(); let (selected, placement) = match &usage.target { Target::Whole => (usage.surface.id(), None), @@ -335,7 +404,10 @@ impl App { }; self.layer = diagram.layer; self.active_diagram = Some(id); - if let Some(machine) = diagram.projection.state_machine() { + if let Some(owner) = diagram.projection.scenario() { + self.behavior.active_owner = Some(owner); + self.page = Page::Behavior; + } else if let Some(machine) = diagram.projection.state_machine() { let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new( (self.window_size.width - 610.).max(280.), (self.window_size.height - 320.).max(240.), @@ -377,19 +449,14 @@ impl App { self.page = Page::Behavior; } Surface::Scenario(owner) => { - if !self.behavior.scenario.reveal_usage( - p, - self.canvas_document, - owner, - &usage.target, - ) { - return false; - } self.layer = p.elements[&owner].layer(); self.behavior.active_owner = Some(owner); self.page = Page::Behavior; } } + if scenario_representation.is_none() { + self.behavior.scenario.forget_view(); + } self.selected = selected; self.selected_node = placement; self.model_menu = None; @@ -416,6 +483,18 @@ impl App { usage: usage.clone(), selected, }); + self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| { + let owner = presentation::checked_owner(self.project(), representation)?; + self.behavior + .scenario + .context( + self.canvas_document, + owner, + self.document.revision(), + self.canvas_epoch, + ) + .map(|context| context.visit) + }); self.pending_diagram_reveal = Some(( Context { document: self.canvas_document, @@ -436,10 +515,20 @@ impl App { Surface::Saved(id) => { self.active_diagram == Some(id) && matches!(self.page, Page::Diagram | Page::Behavior) + && (!self.saved_scenario_diagram(id) + || (self.page == Page::Behavior + && self.behavior.scenario.representation() + == Some(Representation::Saved { diagram: id }))) } - Surface::Process(owner) | Surface::Scenario(owner) => { + Surface::Process(owner) => { self.page == Page::Behavior && self.behavior.active_owner == Some(owner) } + Surface::Scenario(owner) => { + self.page == Page::Behavior + && self.behavior.active_owner == Some(owner) + && self.behavior.scenario.representation() + == Some(Representation::Derived { owner }) + } }; (current_surface && focus.usage.surface == surface diff --git a/crates/oa-desktop/src/app/diagram_usages/tests.rs b/crates/oa-desktop/src/app/diagram_usages/tests.rs index 95b0089d..adced1b1 100644 --- a/crates/oa-desktop/src/app/diagram_usages/tests.rs +++ b/crates/oa-desktop/src/app/diagram_usages/tests.rs @@ -1400,3 +1400,5 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() { assert_eq!(location(&app), prior); assert_eq!(authored(&app), before); } + +mod saved_scenario; diff --git a/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.arcadia b/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.arcadia new file mode 100644 index 00000000..70f8a4c4 --- /dev/null +++ b/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.arcadia @@ -0,0 +1,108 @@ +arcadia 7; +project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" { + // Two operational activities, with one lifeline appearance per Scenario. + oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {} + oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {} + interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" { + source @report_incident; + target @assess_incident; + } + + oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" { + viewpoint OperationalActivity; + steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review]; + } + oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" { + parent @incident_response; represented @report_incident; + } + oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" { + parent @incident_response; represented @assess_incident; + } + // Both appearances reuse one definition. Navigation must retain the occurrence. + oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" { + parent @incident_response; + body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; }; + } + oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" { + parent @incident_response; + body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; }; + } + // A correlated self call/reply with deliberately unassigned exchange definitions. + oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" { + parent @incident_response; + body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; }; + } + oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" { + parent @incident_response; + body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; }; + } + // Intentional unfinished work: no receiver and therefore no fabricated arrow. + oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" { + parent @incident_response; + body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; }; + } + oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" { + parent @incident_response; + body { ScenarioUse { + bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }]; + scenario @reporting_review; + }; }; + } + + // This definition stays opaque in the caller. Open it, then Back to the saved view. + oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" { + viewpoint OperationalActivity; + steps [@review_delivery]; + } + oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" { + parent @reporting_review; represented @report_incident; + } + oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" { + parent @reporting_review; represented @assess_incident; + } + oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" { + parent @reporting_review; + body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; }; + } + + diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" { + layer Oa; + projection { Scenario { scenario @incident_response; }; }; + layout { Scenario { + lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }]; + messages [ + { address { references []; occurrence @initial_report; }; send_y 160; }, + { address { references []; occurrence @updated_report; }; send_y 252; }, + { address { references []; occurrence @assess_call; }; send_y 344; }, + { address { references []; occurrence @assess_reply; }; send_y 436; } + ]; + }; }; + } + diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" { + layer Oa; + projection { Scenario { scenario @incident_response; }; }; + layout { Scenario { + lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }]; + messages [ + { address { references []; occurrence @initial_report; }; send_y 160; }, + { address { references []; occurrence @updated_report; }; send_y 252; }, + { address { references []; occurrence @assess_call; }; send_y 344; }, + { address { references []; occurrence @assess_reply; }; send_y 436; } + ]; + }; }; + } + diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" { + layer Oa; + projection { Scenario { scenario @incident_response; }; }; + layout { Scenario { + // These valid authored lanes must remain saved during complete fallback. + lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }]; + messages [ + // REPAIR: change only this 300 to 424, then Undo and Redo the edit. + { address { references []; occurrence @assess_reply; }; send_y 300; }, + // This authored value stays latent while the receiver is missing. + { address { references []; occurrence @unfinished_notice; }; send_y 2000; } + ]; + }; }; + } +} diff --git a/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.rs b/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.rs new file mode 100644 index 00000000..3b107eaf --- /dev/null +++ b/crates/oa-desktop/src/app/diagram_usages/tests/saved_scenario.rs @@ -0,0 +1,362 @@ +//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history. +use super::*; +use crate::behavior::scenario::{CanvasEvent, State}; +use crate::behavior::scenario_layout::{ + Representation, + presentation::{self, PresentationStatus}, +}; +use iced::{Size, Vector}; + +fn fixture() -> App { + let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap(); + let errors: Vec<_> = oa_core::validation::validate(&p) + .into_iter() + .filter(|d| d.severity == oa_core::validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + App::with_project(p) +} +fn id(app: &App, alias: &str) -> Id { + app.project() + .identities() + .into_iter() + .find(|(_, a)| *a == alias) + .unwrap() + .0 +} +fn representation(view: Id) -> Representation { + Representation::Saved { diagram: view } +} +fn find(app: &App, subject: Id, view: Id) -> Usage { + usage::saved_usages_for(app.project(), subject, Some(view)) + .into_iter() + .find(|row| row.target == Target::ScenarioObject(subject)) + .expect("exact saved occurrence") +} +fn context(app: &App) -> behavior::scenario::Context { + let owner = app.behavior.active_owner.unwrap(); + app.behavior + .scenario + .context( + app.canvas_document, + owner, + app.document.revision(), + app.canvas_epoch, + ) + .unwrap() +} +fn navigate(app: &mut App, subject: Id, view: Id) -> Id { + let (token, rows) = open(app, subject); + let found = rows + .into_iter() + .find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject)) + .expect("saved chooser destination"); + choose(app, token, found); + assert!(app.diagram_usages.is_none()); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.active_diagram, Some(view)); + assert_eq!( + app.behavior.scenario.representation(), + Some(representation(view)) + ); + assert_eq!(app.selected, subject); + token +} + +#[test] +fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque() +{ + let app = fixture(); + let owner = id(&app, "incident_response"); + let first = id(&app, "initial_report"); + let second = id(&app, "updated_report"); + let exchange = id(&app, "incident_report"); + let latent = id(&app, "unfinished_notice"); + let callee_message = id(&app, "review_delivery"); + let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a)); + let derived = app + .behavior + .scenario + .diagram_usages(app.project(), owner, first) + .pop() + .unwrap(); + let rows = views.map(|view| find(&app, first, view)); + assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.); + assert_ne!(rows[0].bounds, rows[1].bounds); + assert_eq!( + rows[2].bounds, derived.bounds, + "complete fallback cannot retain the authored lane override" + ); + for view in views { + let represented = usage::saved_usages_for(app.project(), exchange, Some(view)); + assert_eq!(represented.len(), 2); + assert!( + represented + .iter() + .any(|u| u.target == Target::ScenarioObject(first)) + ); + assert!( + represented + .iter() + .any(|u| u.target == Target::ScenarioObject(second)) + ); + assert!( + represented + .iter() + .all(|u| u.surface == Surface::Saved(view)) + ); + assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty()); + assert_eq!( + find(&app, latent, view).target, + Target::ScenarioObject(latent) + ); + let shown = presentation::present(app.project(), representation(view), owner); + let whole = usage::saved_usages_for(app.project(), view, Some(view)); + assert_eq!(whole.len(), 1); + assert_eq!(whole[0].target, Target::Whole); + assert_eq!(Some(whole[0].bounds), shown.bounds()); + } + let wide = presentation::present(app.project(), representation(views[1]), owner) + .bounds() + .unwrap(); + assert!(wide.x < -500. && wide.x + wide.width > 50000.); + let fallback = presentation::present(app.project(), representation(views[2]), owner); + assert!(matches!( + fallback.status(), + PresentationStatus::Fallback { .. } + )); + assert_eq!(fallback.latent_messages(), &[latent]); + assert!(!fallback.capabilities().coordinate_input); +} + +#[test] +fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() { + for alias in ["incident_readable", "incident_wide", "incident_repair"] { + let mut app = fixture(); + let view = id(&app, alias); + let first = id(&app, "initial_report"); + let owner = id(&app, "incident_response"); + let original = app.document.snapshot(); + app.document + .execute(Command::Rename(first, "Reviewed incident report".into())) + .unwrap(); + let edited = app.document.snapshot(); + app.sync_source(); + app.refresh_draft(); + let before = authored(&app); + navigate(&mut app, first, view); + assert_eq!(app.behavior.active_owner, Some(owner)); + assert_eq!(authored(&app), before); + send(&mut app, Message::RevealInDiagram(view, first)); + assert_eq!(app.active_diagram, Some(view)); + assert_eq!(authored(&app), before); + assert!(app.document.undo()); + assert_eq!(app.project(), &original.decoded.project); + assert_eq!(app.document.bundle(), &original.bundle); + assert!(app.document.redo()); + assert_eq!(app.project(), &edited.decoded.project); + assert_eq!(app.document.bundle(), &edited.bundle); + } +} + +#[test] +fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() { + for chooser in [false, true] { + for block in 0..3 { + let mut app = fixture(); + let view = id(&app, "incident_repair"); + let first = id(&app, "initial_report"); + let choice = chooser.then(|| { + let (token, rows) = open(&mut app, first); + ( + token, + rows.into_iter() + .find(|u| u.surface == Surface::Saved(view)) + .unwrap(), + ) + }); + match block { + 0 => { + let cursor = app.source_editor.cursor(); + app.source_workspace + .edit("? preserve unfinished source".into(), cursor); + } + 1 => { + app.draft + .as_mut() + .unwrap() + .input("/name", "Unapplied inspector value".into()); + assert!(app.draft_dirty()); + } + _ => { + let p = app.project().clone(); + app.behavior + .creation + .update(&p, behavior::creation::Event::Open(Layer::Oa)) + .unwrap(); + assert!(app.behavior.has_pending()); + } + } + let before = authored(&app); + let prior = location(&app); + let draft = app + .draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()); + if let Some((token, row)) = choice { + choose(&mut app, token, row); + } else { + send(&mut app, Message::RevealInDiagram(view, first)); + } + assert_eq!(authored(&app), before); + assert_eq!(location(&app), prior); + assert_eq!( + app.draft + .as_ref() + .map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()), + draft + ); + } + } +} + +#[test] +fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() { + let mut app = fixture(); + let a = id(&app, "incident_readable"); + let b = id(&app, "incident_wide"); + let first = id(&app, "initial_report"); + let before = authored(&app); + let old_token = navigate(&mut app, first, a); + let old = context(&app); + navigate(&mut app, first, b); + navigate(&mut app, first, a); + let current = context(&app); + assert_ne!(old.visit, current.visit); + let held = behavior::scenario::Context { + epoch: current.epoch, + ..old + }; + let camera = app.behavior.scenario.camera_boundary(); + send( + &mut app, + Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))), + ); + send( + &mut app, + Message::ScenarioCanvas(CanvasEvent::Shelf( + held, + crate::behavior::scenario::Shelf::Selection, + )), + ); + send( + &mut app, + Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())), + ); + send(&mut app, Message::DiagramUsage(Event::Close(old_token))); + assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit)); + assert!(app.pending_diagram_reveal.is_some()); + assert!(app.behavior.scenario.camera_boundary() == camera); + let bounds = find(&app, first, a).bounds; + send( + &mut app, + Message::ScenarioCanvas(CanvasEvent::Viewport(current, Size::new(500., 300.))), + ); + assert!(app.pending_diagram_reveal.is_none()); + assert_eq!(context(&app).visit, current.visit); + let mut expected = State::default(); + expected.enter_representation(app.canvas_document, current.scenario, representation(a)); + expected.viewport(Size::new(500., 300.)); + expected.zoom(Some(1.25)); + expected.camera( + app.project(), + current.scenario, + behavior::canvas_workspace::Event::Pan(Vector::new( + 250. - bounds.center_x() * 1.25, + 150. - bounds.center_y() * 1.25, + )), + ); + assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary()); + assert_eq!(authored(&app), before); +} + +#[test] +fn retargeted_saved_view_cannot_reveal_a_former_owner_occurrence() { + let mut app = fixture(); + let first = id(&app, "initial_report"); + let view = id(&app, "incident_readable"); + let other = id(&app, "reporting_review"); + let (token, rows) = open(&mut app, first); + let old = rows + .into_iter() + .find(|u| u.surface == Surface::Saved(view)) + .unwrap(); + let mut replacement = app.project().diagrams[&view].clone(); + replacement.projection = ProjectionKind::Scenario { scenario: other }; + replacement.layout = None; + app.document + .execute(Command::EditDiagram(view, replacement)) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + let before = authored(&app); + let prior = location(&app); + choose(&mut app, token, old); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); + finish_lookup(&mut app); + let token = app.diagram_usages.as_ref().unwrap().token; + assert!( + app.diagram_usages + .as_ref() + .unwrap() + .usages + .iter() + .all(|u| u.surface != Surface::Saved(view)) + ); + send(&mut app, Message::DiagramUsage(Event::Close(token))); + send(&mut app, Message::RevealInDiagram(view, first)); + assert_eq!(location(&app), prior); + assert_eq!(authored(&app), before); +} + +#[test] +fn explicit_saved_layout_repair_undo_redo_requeries_authored_and_fallback_geometry_without_extra_history() + { + let mut app = fixture(); + let view = id(&app, "incident_repair"); + let first = id(&app, "initial_report"); + let reply = id(&app, "assess_reply"); + let before = app.document.snapshot(); + let fallback = find(&app, first, view); + app.document + .execute(Command::SetScenarioMessagePosition { + diagram: view, + position: oa_core::ScenarioMessagePosition { + address: oa_core::OccurrenceEndpoint { + references: vec![], + occurrence: reply, + }, + send_y: 424., + }, + }) + .unwrap(); + app.sync_source(); + app.refresh_draft(); + let repaired = app.document.snapshot(); + let authored_row = find(&app, first, view); + assert_ne!(authored_row.bounds, fallback.bounds); + navigate(&mut app, first, view); + assert!(app.document.undo()); + app.sync_source(); + app.refresh_draft(); + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(find(&app, first, view).bounds, fallback.bounds); + assert!(app.document.redo()); + app.sync_source(); + app.refresh_draft(); + assert_eq!(app.project(), &repaired.decoded.project); + assert_eq!(app.document.bundle(), &repaired.bundle); + assert_eq!(find(&app, first, view).bounds, authored_row.bounds); +} diff --git a/crates/oa-desktop/src/app/process_canvas.rs b/crates/oa-desktop/src/app/process_canvas.rs index b578f377..d17e7a0d 100644 --- a/crates/oa-desktop/src/app/process_canvas.rs +++ b/crates/oa-desktop/src/app/process_canvas.rs @@ -65,6 +65,7 @@ impl App { owner: context.owner, revision: context.revision, epoch: lifetime, + scenario_view: None, }; if self.behavior.has_pending() || self.draft_dirty() diff --git a/crates/oa-desktop/src/app/scenario_details_tests.rs b/crates/oa-desktop/src/app/scenario_details_tests.rs new file mode 100644 index 00000000..d25e66ef --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_details_tests.rs @@ -0,0 +1,279 @@ +//! Actual arrangement selection and current-record Details admission. +use super::*; +use crate::behavior::canvas_workspace::tests::Mounted; +use crate::behavior::scenario::{CanvasEvent, Context, Shelf}; +use crate::behavior::scenario_layout::Representation; +use iced::{Point, Size, advanced::widget}; + +const FIXTURE: &str = include_str!("diagram_usages/tests/saved_scenario.arcadia"); + +fn id(app: &App, alias: &str) -> Id { + app.project() + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} +fn setup() -> (App, Id, Id, Id) { + let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap()); + let a = id(&app, "incident_readable"); + let b = id(&app, "incident_wide"); + let owner = id(&app, "incident_response"); + let _ = app.update(Message::Select(owner)); + let _ = app.update(Message::Page(Page::Behavior)); + choose(&mut app, Representation::Saved { diagram: a }); + let _ = app.update(Message::Select(a)); + app.window_size = Size::new(1440., 1000.); + (app, a, b, owner) +} +fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) { + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(event(context))); +} +fn choose(app: &mut App, representation: Representation) { + send(app, |context| { + CanvasEvent::Representation(context, representation) + }); +} +fn model_state(app: &App) -> (u64, bool, bool, bool) { + ( + app.document.revision(), + app.document.dirty(), + app.document.can_undo(), + app.document.can_redo(), + ) +} + +#[tokio::test] +async fn actual_arrangement_picker_follows_selected_saved_record_and_opens_b_properties() { + let (mut app, a, b, _) = setup(); + let mut mounted = Mounted::new(&mut app).await; + let snapshot = app.document.snapshot(); + let before = model_state(&app); + let captured = app.scenario_context().unwrap(); + let picker = mounted.geometry(&app).bounds("scenario-arrangement-picker"); + assert!( + app.window_size.height - (picker.y + picker.height) > picker.y, + "fixture has room for Iced's menu below the actual picker" + ); + assert_eq!(app.selected, a); + mounted.click(&mut app, picker.center()).await; + // iced_widget 0.14.2 overlay/menu.rs uses default line height plus + // vertical padding for each row. The real workspace orders Automatic, + // then the fixture's A/B/C Diagram IDs. Select B from the actual overlay. + let row_height = + f32::from(iced::advanced::text::LineHeight::default().to_absolute(12.into())) + 10.; + let b_row = Point::new( + picker.center_x(), + picker.y + picker.height + row_height * 2.5, + ); + assert!(b_row.y < app.window_size.height); + let events = mounted.click(&mut app, b_row).await; + assert!(events.iter().any(|event| matches!(event, + Message::ScenarioCanvas(CanvasEvent::Representation(context, Representation::Saved { diagram })) + if *context == captured && *diagram == b)), "must exercise the actual arrangement callback"); + assert_eq!(app.selected, b); + assert_eq!(app.draft.as_ref().unwrap().original, b); + assert_eq!( + app.draft.as_ref().unwrap().value["name"], + "B - Incident review negative and far" + ); + assert_eq!(app.active_diagram, Some(b)); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { diagram: b } + ); + assert_ne!(app.scenario_context().unwrap().visit, captured.visit); + + send(&mut app, |context| { + CanvasEvent::Shelf(context, Shelf::Selection) + }); + mounted.settle(&mut app).await; + let geometry = mounted.geometry(&app); + let (details, offset) = geometry.scrolls[&widget::Id::new("scenario-details")]; + let point = geometry.bounds("scenario-properties").center() - offset; + assert!(details.contains(point)); + let events = mounted.click(&mut app, point).await; + assert!(events.iter().any(|event| matches!(event, + Message::ScenarioCanvas(CanvasEvent::Properties(_, selected)) if *selected == b))); + mounted.settle(&mut app).await; + let geometry = mounted.geometry(&app); + let (inspector, offset) = geometry.scrolls[&widget::Id::new("inspector")]; + assert!( + geometry + .text_inputs + .iter() + .any(|bounds| inspector.contains(bounds.center() - offset)), + "current B Properties must expose an actual editable field" + ); + assert!( + !geometry + .scrolls + .contains_key(&widget::Id::new("scenario-details")) + ); + assert_eq!( + app.inspector_tab, + crate::navigation::InspectorTab::Properties + ); + assert_eq!(app.selected, b); + assert_eq!(app.draft.as_ref().unwrap().original, b); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.project(), &snapshot.decoded.project); + assert_eq!(app.document.bundle(), &snapshot.bundle); + assert_eq!(model_state(&app), before); +} + +#[test] +fn arrangement_change_preserves_independent_semantic_and_foreign_view_selection() { + for alias in [ + "initial_report", + "reporter", + "incident_response", + "incident_repair", + ] { + let (mut app, _, b, _) = setup(); + let selected = id(&app, alias); + let _ = app.update(Message::Select(selected)); + let snapshot = app.document.snapshot(); + let before = model_state(&app); + choose(&mut app, Representation::Saved { diagram: b }); + assert_eq!(app.selected, selected, "independent selection {alias}"); + assert_eq!(app.draft.as_ref().unwrap().original, selected); + assert_eq!(app.active_diagram, Some(b)); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &snapshot.bundle); + assert_eq!(model_state(&app), before); + if alias == "incident_repair" { + assert!(!behavior::scenario::properties_target( + app.project(), + app.scenario_context().unwrap(), + selected + )); + } + } +} + +#[test] +fn arrangement_follows_view_record_to_automatic_owner_but_not_owner_back_to_saved() { + let (mut app, _, b, owner) = setup(); + let snapshot = app.document.snapshot(); + let before = model_state(&app); + choose(&mut app, Representation::Derived { owner }); + assert_eq!(app.selected, owner); + assert_eq!(app.draft.as_ref().unwrap().original, owner); + assert_eq!(app.active_diagram, None); + assert!(behavior::scenario::properties_target( + app.project(), + app.scenario_context().unwrap(), + owner + )); + choose(&mut app, Representation::Saved { diagram: b }); + assert_eq!( + app.selected, owner, + "Scenario owner is now independent semantic selection" + ); + assert_eq!(app.draft.as_ref().unwrap().original, owner); + assert_eq!(app.active_diagram, Some(b)); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &snapshot.bundle); + assert_eq!(model_state(&app), before); +} + +#[test] +fn arrangement_applies_only_the_old_view_draft_then_follows_b_with_one_undo() { + let (mut app, a, b, _) = setup(); + let snapshot = app.document.snapshot(); + let mut expected = app.project().clone(); + expected.diagrams.get_mut(&a).unwrap().name = "Reviewed A".into(); + let revision = app.document.revision(); + let _ = app.update(Message::Field("/name".into(), "Reviewed A".into())); + choose(&mut app, Representation::Saved { diagram: b }); + assert_eq!(app.project(), &expected); + assert_eq!(app.selected, b); + assert_eq!(app.draft.as_ref().unwrap().original, b); + assert!(!app.draft_dirty()); + assert_eq!(app.document.revision(), revision + 1); + let accepted = app.document.snapshot(); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &snapshot.decoded.project); + assert_eq!(app.document.bundle(), &snapshot.bundle); + assert!(!app.document.can_undo() && app.document.can_redo()); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert!(app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn same_arrangement_uuid_edit_follows_the_reidentified_record_and_round_trips_source() { + let (mut app, a, _, _) = setup(); + let snapshot = app.document.snapshot(); + let replacement = Id::parse_str("70200000-0000-4000-8000-000000000099").unwrap(); + let mut expected = app.project().clone(); + let mut diagram = expected.diagrams.remove(&a).unwrap(); + diagram.id = replacement; + expected.diagrams.insert(replacement, diagram); + let _ = app.update(Message::Field("/id".into(), replacement.to_string())); + choose(&mut app, Representation::Saved { diagram: a }); + assert_eq!(app.project(), &expected); + assert_eq!(app.selected, replacement); + assert_eq!(app.draft.as_ref().unwrap().original, replacement); + assert_eq!(app.active_diagram, Some(replacement)); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { + diagram: replacement + } + ); + let accepted = app.document.snapshot(); + let _ = app.update(Message::Undo); + assert_eq!(app.project(), &snapshot.decoded.project); + assert_eq!(app.document.bundle(), &snapshot.bundle); + assert!(!app.document.can_undo() && app.document.can_redo()); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert!(!app.document.can_redo()); +} + +#[test] +fn invalid_draft_and_stale_arrangement_preserve_selection_inputs_and_accepted_state() { + let (mut app, a, b, _) = setup(); + let old = app.scenario_context().unwrap(); + let snapshot = app.document.snapshot(); + let before = model_state(&app); + let _ = app.update(Message::Field("/id".into(), "not-a-uuid".into())); + let draft_value = app.draft.as_ref().unwrap().value.clone(); + choose(&mut app, Representation::Saved { diagram: b }); + assert_eq!(app.selected, a); + assert_eq!(app.active_diagram, Some(a)); + assert_eq!( + app.scenario_context().unwrap().representation, + old.representation + ); + assert_eq!(app.draft.as_ref().unwrap().value, draft_value); + assert_eq!(app.draft.as_ref().unwrap().inputs["/id"].raw, "not-a-uuid"); + assert!(app.draft_dirty()); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(model_state(&app), before); + let _ = app.update(Message::RevertProperties); + choose(&mut app, Representation::Saved { diagram: b }); + let current = app.scenario_context().unwrap(); + let _ = app.update(Message::Field("/name".into(), "Retained B input".into())); + let selected = app.selected; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation( + old, + Representation::Saved { diagram: a }, + ))); + assert_eq!(app.selected, selected); + assert_eq!(app.active_diagram, Some(b)); + assert_eq!(app.scenario_context().unwrap().visit, current.visit); + assert_eq!( + app.draft.as_ref().unwrap().inputs["/name"].raw, + "Retained B input" + ); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &snapshot.bundle); + assert_eq!(model_state(&app), before); +} diff --git a/crates/oa-desktop/src/app/scenario_diagram.rs b/crates/oa-desktop/src/app/scenario_diagram.rs new file mode 100644 index 00000000..5014830d --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_diagram.rs @@ -0,0 +1,96 @@ +//! Saved Scenario navigation is presentation state, never a document edit. +use super::*; +use crate::behavior::scenario_layout::{Representation, presentation::checked_owner}; + +impl App { + pub(super) fn enter_scenario_representation(&mut self, representation: Representation) -> bool { + let Some(owner) = checked_owner(self.project(), representation) else { + self.status = + "This Scenario view is no longer available or has an incompatible owner.".into(); + return false; + }; + self.layer = self.project().elements[&owner].layer(); + self.page = Page::Behavior; + self.behavior.active_owner = Some(owner); + match representation { + Representation::Saved { diagram } => self.active_diagram = Some(diagram), + Representation::Derived { .. } => { + if self + .active_diagram + .is_some_and(|id| self.saved_scenario_diagram(id)) + { + self.active_diagram = None; + } + } + } + self.selected_node = None; + if self + .behavior + .scenario + .enter_representation(self.canvas_document, owner, representation) + { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + true + } + + /// Reconcile after navigation/adoption without selecting or applying a draft. + /// A retained invalid Saved identity remains unavailable; it must not silently + /// turn into a different automatic Scenario or another saved arrangement. + pub(super) fn reconcile_scenario_workspace(&mut self) { + if self.page == Page::Diagram + && !self.behavior.has_pending() + && let Some(diagram) = self + .active_diagram + .filter(|id| self.saved_scenario_diagram(*id)) + { + self.enter_scenario_representation(Representation::Saved { diagram }); + } + if self.page != Page::Behavior { + self.behavior.scenario.leave(); + return; + } + if let Some(rep @ Representation::Saved { diagram }) = + self.behavior.scenario.representation() + { + // The requested view is authoritative until explicit navigation + // retires it. This also recovers the same view when Undo restores it. + if let Some(owner) = checked_owner(self.project(), rep) { + self.active_diagram = Some(diagram); + self.layer = self.project().elements[&owner].layer(); + self.behavior.active_owner = Some(owner); + self.behavior + .scenario + .enter_representation(self.canvas_document, owner, rep); + } else if let Some(owner) = self.behavior.scenario.owner() { + self.behavior + .scenario + .enter_representation(self.canvas_document, owner, rep); + } + return; + } + let Some(owner) = behavior::selected_owner(self) else { + self.behavior.scenario.leave(); + return; + }; + if self + .project() + .elements + .get(&owner) + .is_some_and(|e| e.kind() == Kind::Scenario) + { + self.behavior.scenario.enter(self.canvas_document, owner); + } else { + self.behavior.scenario.leave(); + } + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod adoption_tests; + +#[cfg(test)] +mod owner_navigation_tests; diff --git a/crates/oa-desktop/src/app/scenario_diagram/adoption_tests.rs b/crates/oa-desktop/src/app/scenario_diagram/adoption_tests.rs new file mode 100644 index 00000000..d45e6e77 --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_diagram/adoption_tests.rs @@ -0,0 +1,264 @@ +//! Independent adoption oracles: expected Projects are edited directly in the +//! fixture, then admitted through the actual source editor/Document transaction. +//! Navigation is checked separately from the expected authored mutation. +use super::*; +use crate::behavior::canvas_workspace::tests::Mounted; +use iced::advanced::widget; +use iced::widget::text_editor; +use oa_core::language; +use std::sync::Arc; + +struct Fixture { + app: App, + owner: Id, + same_layer: Id, + other_layer: Id, + view: Id, + spare_view: Id, +} + +fn fixture() -> Fixture { + let mut project = Project::empty(); + let owner = project + .add_element(Kind::Scenario, Layer::Oa, "Original owner", None) + .unwrap(); + let same_layer = project + .add_element(Kind::Scenario, Layer::Oa, "Other OA owner", None) + .unwrap(); + let other_layer = project + .add_element(Kind::Scenario, Layer::Sa, "SA owner", None) + .unwrap(); + // A remaining topology makes silent fallback observably different from the + // required unavailable Saved view after its owner is deleted. + project + .add_element(Kind::Process, Layer::Oa, "Remaining process", None) + .unwrap(); + let mut make_view = |alias: &str, name: &str, owner| { + let id = Id::new_v4(); + project.diagrams.insert( + id, + Diagram { + id, + alias: alias.into(), + name: name.into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + layout: None, + nodes: Default::default(), + routes: vec![], + view_options: Default::default(), + }, + ); + id + }; + let view = make_view("retained_view", "Retained arrangement", owner); + let spare_view = make_view("spare_view", "Spare arrangement", same_layer); + let mut app = App::with_project(project); + let _ = app.update(Message::Select(owner)); + choose(&mut app, view); + assert_saved(&app, view, owner, Layer::Oa); + assert!(!app.document.can_undo()); + Fixture { + app, + owner, + same_layer, + other_layer, + view, + spare_view, + } +} + +fn choose(app: &mut App, id: Id) { + let _ = app.update(Message::Diagram(Choice { + id, + label: "Previously captured picker item".into(), + })); +} + +fn assert_saved(app: &App, view: Id, owner: Id, layer: Layer) { + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.layer, layer); + assert_eq!(app.active_diagram, Some(view)); + let context = app + .scenario_context() + .expect("saved owner remains the visible context"); + assert_eq!(context.scenario, owner); + assert_eq!( + context.representation, + Representation::Saved { diagram: view } + ); + assert!(!context.visit.is_nil()); +} + +fn adopt_from_source(app: &mut App, expected: &Project) { + let text = language::format(expected); + // Parser equality is a fixture precondition, not the navigation oracle. + assert_eq!(language::parse(&text).unwrap(), *expected); + let _ = app.update(Message::Page(Page::Text)); + let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll)); + let _ = app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Paste(Arc::new(text)), + ))); + assert!(app.source_dirty()); + let _ = app.update(Message::ApplySource); + assert!(!app.source_dirty(), "{}", app.status); + assert_eq!( + app.project(), + expected, + "actual source transaction must adopt exactly the fixture change" + ); + let _ = app.update(Message::Page(Page::Behavior)); +} + +fn retarget(cross_layer: bool) { + let mut f = fixture(); + let before = f.app.document.snapshot(); + let old_context = f.app.scenario_context().unwrap(); + let revision = f.app.document.revision(); + let target = if cross_layer { + f.other_layer + } else { + f.same_layer + }; + let layer = if cross_layer { Layer::Sa } else { Layer::Oa }; + let mut expected = before.decoded.project.clone(); + let view = expected.diagrams.get_mut(&f.view).unwrap(); + view.projection = ProjectionKind::Scenario { scenario: target }; + view.layer = layer; + + adopt_from_source(&mut f.app, &expected); + assert_saved(&f.app, f.view, target, layer); + assert_ne!(f.app.scenario_context().unwrap().visit, old_context.visit); + assert_eq!( + f.app.document.revision(), + revision + 1, + "routing must not create a second transaction" + ); + assert_eq!( + f.app.project().diagrams[&f.spare_view], + before.decoded.project.diagrams[&f.spare_view] + ); + let adopted_bundle = f.app.document.bundle().clone(); + + let _ = f.app.update(Message::Undo); + assert_saved(&f.app, f.view, f.owner, Layer::Oa); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert!( + !f.app.document.can_undo(), + "exactly one authored adoption must be undone" + ); + let _ = f.app.update(Message::Redo); + assert_saved(&f.app, f.view, target, layer); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &adopted_bundle); + assert!(!f.app.document.can_redo()); +} + +#[test] +fn source_same_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() { + retarget(false); +} + +#[test] +fn source_cross_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() { + retarget(true); +} + +#[tokio::test] +async fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() { + let mut f = fixture(); + let before = f.app.document.snapshot(); + let mut expected = before.decoded.project.clone(); + assert!(expected.elements.remove(&f.owner).is_some()); + assert!(expected.diagrams.remove(&f.view).is_some()); + assert!(expected.elements.contains_key(&f.same_layer)); + assert!( + expected + .elements + .values() + .any(|e| e.kind() == Kind::Process) + ); + adopt_from_source(&mut f.app, &expected); + assert_eq!( + f.app.behavior.scenario.representation(), + Some(Representation::Saved { diagram: f.view }) + ); + let adopted = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let mut mounted = Mounted::new(&mut f.app).await; + let geometry = mounted.geometry(&f.app); + assert!( + geometry + .containers + .contains_key(&widget::Id::from("scenario-viewport-unavailable")) + ); + assert!( + !geometry + .containers + .contains_key(&widget::Id::from("scenario-viewport")) + ); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &adopted.bundle); + assert_eq!( + f.app.document.revision(), + revision, + "mounting unavailable must remain passive" + ); + + let _ = f.app.update(Message::Undo); + assert_saved(&f.app, f.view, f.owner, Layer::Oa); + assert_eq!(f.app.document.bundle(), &before.bundle); + let _ = f.app.update(Message::Redo); + assert_eq!( + f.app.behavior.scenario.representation(), + Some(Representation::Saved { diagram: f.view }) + ); + mounted.settle(&mut f.app).await; + assert!( + mounted + .geometry(&f.app) + .containers + .contains_key(&widget::Id::from("scenario-viewport-unavailable")) + ); + assert_eq!(f.app.project(), &expected); + assert_eq!(f.app.document.bundle(), &adopted.bundle); +} + +#[test] +fn missing_and_deleted_picker_ids_cannot_apply_an_unrelated_valid_property_draft() { + let mut f = fixture(); + let mut expected = f.app.project().clone(); + expected.diagrams.remove(&f.spare_view).unwrap(); + adopt_from_source(&mut f.app, &expected); + let _ = f.app.update(Message::Select(f.owner)); + let _ = f.app.update(Message::Field( + "/name".into(), + "Pending valid owner rename".into(), + )); + assert!(f.app.draft_dirty()); + let before = f.app.document.snapshot(); + let revision = f.app.document.revision(); + let selected = f.app.selected; + for invalid in [Id::nil(), f.spare_view] { + choose(&mut f.app, invalid); + assert_saved(&f.app, f.view, f.owner, Layer::Oa); + assert_eq!(f.app.selected, selected); + assert!(f.app.draft_dirty()); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); + assert_eq!(f.app.document.revision(), revision); + } + // Establish that the retained draft really was valid, not merely refused by + // the property validator: explicit Apply commits it in exactly one step. + assert!(f.app.apply(), "{}", f.app.status); + assert_eq!( + f.app.project().elements[&f.owner].name, + "Pending valid owner rename" + ); + assert_eq!(f.app.document.revision(), revision + 1); + let _ = f.app.update(Message::Undo); + assert_eq!(f.app.project(), &before.decoded.project); + assert_eq!(f.app.document.bundle(), &before.bundle); +} diff --git a/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs b/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs new file mode 100644 index 00000000..70211569 --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_diagram/owner_navigation_tests.rs @@ -0,0 +1,116 @@ +//! Explicit owner navigation must retire a retained Saved Scenario, including +//! when selection was made on a different page. These are real App events. +use super::*; +use crate::behavior::scenario::CanvasEvent; + +fn setup(kind: Kind) -> (App, Id, Id) { + let mut project = Project::empty(); + let scenario = project + .add_element(Kind::Scenario, Layer::Oa, "Incident review", None) + .unwrap(); + let target = project + .add_element(kind, Layer::Oa, "Explicit destination", None) + .unwrap(); + let view = Id::new_v4(); + project.diagrams.insert( + view, + Diagram { + id: view, + alias: "incident_saved".into(), + name: "Incident saved arrangement".into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario }, + layout: None, + nodes: Default::default(), + routes: vec![], + view_options: Default::default(), + }, + ); + let errors: Vec<_> = oa_core::validation::validate(&project) + .into_iter() + .filter(|issue| issue.severity == oa_core::validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + let mut app = App::with_project(project); + let _ = app.update(Message::Select(scenario)); + let _ = app.update(Message::Diagram(Choice { + id: view, + label: "Incident saved arrangement".into(), + })); + assert_eq!(app.page, Page::Behavior); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { diagram: view } + ); + assert_eq!(app.selected, scenario); + (app, scenario, target) +} + +fn assert_destination(app: &App, target: Id) { + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.selected, target); + assert_eq!(app.behavior.active_owner, Some(target)); + assert_eq!(behavior::selected_owner(app), Some(target)); + assert_eq!( + app.behavior.scenario.representation(), + None, + "explicit non-Scenario destination must not retain an overriding Saved request" + ); + assert!(app.scenario_context().is_none()); +} + +fn exercise(via_guide: bool) { + for kind in [Kind::Process, Kind::StateMachine] { + let (mut app, scenario, target) = setup(kind); + assert_ne!(scenario, target); + let held = app.scenario_context().unwrap(); + let accepted = app.document.snapshot(); + let drafts = app.source_workspace.bundle(); + let revision = app.document.revision(); + let dirty = (app.document.dirty(), app.dirty(), app.source_dirty()); + assert!(!app.document.can_undo() && !app.document.can_redo()); + if via_guide { + let _ = app.update(Message::Page(Page::Guide)); + assert_eq!(app.page, Page::Guide); + assert!(app.scenario_context().is_none()); + } + let _ = app.update(Message::Select(target)); + if via_guide { + assert_eq!( + app.page, + Page::Guide, + "explorer selection must not itself switch the page" + ); + let _ = app.update(Message::Page(Page::Behavior)); + } + assert_destination(&app, target); + let camera = app.camera_boundary(); + // The prior saved canvas cannot regain focus after either owner route. + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Shelf( + held, + crate::behavior::scenario::Shelf::Selection, + ))); + assert_destination(&app, target); + assert!(app.camera_boundary() == camera); + assert_eq!(app.project(), &accepted.decoded.project); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert_eq!(app.source_workspace.bundle(), drafts); + assert_eq!(app.document.revision(), revision); + assert_eq!( + (app.document.dirty(), app.dirty(), app.source_dirty()), + dirty + ); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[test] +fn saved_scenario_guide_selection_returns_to_explicit_process_or_state_owner() { + exercise(true); +} + +#[test] +fn saved_scenario_direct_behavior_selection_enters_explicit_process_or_state_owner() { + exercise(false); +} diff --git a/crates/oa-desktop/src/app/scenario_diagram/tests.rs b/crates/oa-desktop/src/app/scenario_diagram/tests.rs new file mode 100644 index 00000000..e46d8a33 --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_diagram/tests.rs @@ -0,0 +1,205 @@ +use super::*; +use crate::behavior::scenario::{CanvasEvent, ScrollArea}; + +fn saved(p: &mut Project, owner: Id, name: &str) -> Id { + let id = Id::new_v4(); + p.diagrams.insert( + id, + Diagram { + id, + alias: format!("view_{}", p.diagrams.len()), + name: name.into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + layout: None, + routes: vec![], + nodes: Default::default(), + view_options: Default::default(), + }, + ); + id +} +fn setup() -> (App, Id, Id, Id, Id) { + let (mut p, [owner, callee, _], [reference, _, _]) = + behavior::scenario::direct_tests::navigation_fixture(); + let a = saved(&mut p, owner, "Review A"); + let b = saved(&mut p, owner, "Review B"); + let mut app = App::with_project(p); + app.select(owner); + (app, a, b, reference, callee) +} +fn choose(app: &mut App, diagram: Id) { + let _ = app.update(Message::Diagram(Choice { + id: diagram, + label: "Captured view".into(), + })); +} +fn event(app: &mut App, f: impl FnOnce(behavior::scenario::Context) -> CanvasEvent) { + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(f(context))); +} +#[test] +fn saved_view_switches_are_distinct_passive_visits_and_preserve_actual_history() { + let (mut app, a, b, _, _) = setup(); + let original = app.document.snapshot(); + let owner = app.selected; + assert!(app.execute(Command::Rename(owner, "Previously edited caller".into()))); + let edited = app.document.snapshot(); + let edited_revision = app.document.revision(); + choose(&mut app, a); + let first = app.scenario_context().unwrap(); + choose(&mut app, b); + let second = app.scenario_context().unwrap(); + choose(&mut app, a); + let third = app.scenario_context().unwrap(); + assert_eq!(first.scenario, owner); + assert_eq!(first.representation, Representation::Saved { diagram: a }); + assert_eq!(second.representation, Representation::Saved { diagram: b }); + assert_ne!(first.visit, second.visit); + assert_ne!(first.visit, third.visit); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.selected, owner); + assert_eq!(app.document.bundle(), &edited.bundle); + assert_eq!(app.project(), &edited.decoded.project); + assert_eq!(app.document.revision(), edited_revision); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &original.bundle); + assert_eq!(app.project(), &original.decoded.project); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &edited.bundle); + assert_eq!(app.project(), &edited.decoded.project); +} +#[test] +fn saved_view_return_rejects_old_passive_events_without_an_intermediate_frame() { + let (mut app, a, b, _, _) = setup(); + choose(&mut app, a); + let old = app.scenario_context().unwrap(); + choose(&mut app, b); + choose(&mut app, a); + let current = app.scenario_context().unwrap(); + let camera = app.behavior.scenario.camera_boundary(); + let before = app.document.snapshot(); + for stale in [ + CanvasEvent::Viewport(old, iced::Size::new(432., 321.)), + CanvasEvent::Scrolled(old, ScrollArea::Page, iced::Vector::new(0., 800.)), + CanvasEvent::FormShown(old, Id::new_v4()), + CanvasEvent::Restored( + old, + Id::new_v4(), + ScrollArea::Page, + iced::Vector::new(0., 800.), + ), + CanvasEvent::Camera( + old, + app.camera_epoch, + behavior::canvas_workspace::Event::Pan(iced::Vector::new(90., 80.)), + ), + ] { + let _ = app.update(Message::ScenarioCanvas(stale)); + assert_eq!(app.scenario_context(), Some(current)); + assert!(app.behavior.scenario.camera_boundary() == camera); + assert_eq!(app.document.bundle(), &before.bundle); + } +} +#[test] +fn saved_reference_back_restores_exact_view_with_a_fresh_visit() { + let (mut app, a, _, reference, callee) = setup(); + choose(&mut app, a); + let caller = app.scenario_context().unwrap(); + let before = app.document.snapshot(); + event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference)); + let target = app.scenario_context().unwrap(); + assert_eq!(target.scenario, callee); + assert_eq!( + target.representation, + Representation::Derived { owner: callee } + ); + assert_eq!(app.active_diagram, None); + event(&mut app, CanvasEvent::Back); + let returned = app.scenario_context().unwrap(); + assert_eq!(returned.representation, caller.representation); + assert_eq!(app.active_diagram, Some(a)); + assert_eq!(app.selected, reference); + assert_ne!(returned.visit, caller.visit); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.project(), &before.decoded.project); + assert!(!app.document.can_undo()); +} +#[test] +fn saved_picker_applies_valid_old_draft_and_uuid_edit_follows_that_same_view() { + let (mut app, a, b, _, _) = setup(); + choose(&mut app, a); + let _ = app.update(Message::Select(a)); + let before = app.document.snapshot(); + let _ = app.update(Message::Field("/name".into(), "Renamed A".into())); + choose(&mut app, b); + assert_eq!(app.project().diagrams[&a].name, "Renamed A"); + assert_eq!(app.selected, b); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { diagram: b } + ); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + choose(&mut app, a); + let replacement = Id::new_v4(); + let _ = app.update(Message::Field("/id".into(), replacement.to_string())); + choose(&mut app, a); + assert!(!app.project().diagrams.contains_key(&a)); + assert_eq!(app.active_diagram, Some(replacement)); + assert_eq!(app.selected, replacement); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { + diagram: replacement + } + ); +} +#[test] +fn leaving_and_reentering_saved_view_preserves_source_and_retires_passive_visit() { + let (mut app, a, _, _, _) = setup(); + choose(&mut app, a); + let old = app.scenario_context().unwrap(); + let before = app.document.snapshot(); + let _ = app.update(Message::Page(Page::Guide)); + assert!(app.scenario_context().is_none()); + let _ = app.update(Message::Page(Page::Behavior)); + let fresh = app.scenario_context().unwrap(); + assert_eq!(fresh.representation, old.representation); + assert_ne!(fresh.visit, old.visit); + assert_eq!(app.document.bundle(), &before.bundle); +} + +#[test] +fn arrangement_picker_rejects_previous_visit_and_switches_automatic_without_model_changes() { + let (mut app, a, b, _, _) = setup(); + choose(&mut app, a); + let stale = app.scenario_context().unwrap(); + choose(&mut app, b); + let current = app.scenario_context().unwrap(); + let before = app.document.snapshot(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation( + stale, + Representation::Saved { diagram: a }, + ))); + assert_eq!(app.scenario_context(), Some(current)); + event(&mut app, |context| { + CanvasEvent::Representation( + context, + Representation::Derived { + owner: context.scenario, + }, + ) + }); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Derived { + owner: current.scenario + } + ); + assert_eq!(app.active_diagram, None); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.project(), &before.decoded.project); + assert!(!app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/app/scenario_spacing.rs b/crates/oa-desktop/src/app/scenario_spacing.rs new file mode 100644 index 00000000..4a3840e6 --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_spacing.rs @@ -0,0 +1,88 @@ +//! Admit spacing packets before ordinary navigation can retire a newer reveal. +use super::*; +use behavior::scenario::spacing::{Event, controller::Finish}; + +#[cfg(test)] +mod tests; + +impl App { + pub(super) fn spacing_enabled(&self) -> bool { + self.pending_diagram_reveal.is_none() + && !self.behavior.has_pending() + && !self.draft_dirty() + && !self.source_dirty() + && !self.source_rename.open() + && behavior::canvas_workspace::keyboard_enabled(self) + } + + pub(super) fn scenario_spacing(&mut self, event: Event) -> Task { + let stamp = event.stamp(); + if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch + { + return Task::none(); + } + let state = &self.behavior.scenario; + let matches = + state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp); + if match event { + Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(), + _ => !matches, + } { + return Task::none(); + } + if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() { + if self.behavior.scenario.cancel_spacing() { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } + return Task::none(); + } + match event { + Event::Begin { .. } => { + let mut state = std::mem::take(&mut self.behavior.scenario); + let result = state.begin_spacing(self.project(), event); + self.behavior.scenario = state; + if result.is_err() { + self.status = "The drag could not start in this view. Try again.".into(); + } + } + Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen), + Event::Finish { screen, .. } => { + let finished = self.behavior.scenario.finish_spacing(screen); + // Retire queued packets even for zero motion or a refused finish. + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + match finished { + Ok(Finish::Click(target)) => self.select(target), + Ok(Finish::Noop) => {} + Ok(Finish::Commit { + command, + selected, + scale, + pan, + }) => { + // Document prepares and writes the source candidate before + // committing its snapshot. Failure leaves camera/selection + // and source/history at their accepted values. + match self.document.execute(*command) { + Ok(_) => { + self.selected = selected; + self.selected_node = None; + self.diagram_navigation_focus = None; + self.behavior.scenario.accept_spacing_camera(scale, pan); + self.sync(); + self.status = "Scenario spacing updated.".into(); + } + Err(error) => self.status = format!("Spacing was not applied: {error}"), + } + } + Err(_) => { + self.status = + "That position cannot be applied. The saved arrangement is unchanged." + .into() + } + } + } + Event::Cancel { .. } => unreachable!(), + } + Task::none() + } +} diff --git a/crates/oa-desktop/src/app/scenario_spacing/tests.rs b/crates/oa-desktop/src/app/scenario_spacing/tests.rs new file mode 100644 index 00000000..1e399b01 --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_spacing/tests.rs @@ -0,0 +1,335 @@ +use super::*; +use behavior::scenario::{ + CanvasEvent, + spacing::{ + Target, + controller::tests::{fixture, packet}, + }, +}; +use iced::{Point, Size, Vector}; + +fn setup() -> (App, Id, Id) { + let (document, owner, diagram, lane, message) = fixture(); + let mut app = App::with_project(document.project().clone()); + let _ = app.update(Message::Select(owner)); + let _ = app.update(Message::Diagram(Choice { + id: diagram, + label: "Saved".into(), + })); + let c = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + c, + Size::new(900., 600.), + ))); + assert!(app.spacing_enabled()); + (app, lane, message) +} + +#[test] +fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture() { + let (mut app, lane, _) = setup(); + let previous = app.scenario_context().unwrap(); + let diagram = match previous.representation { + behavior::scenario_layout::Representation::Saved { diagram } => diagram, + _ => unreachable!(), + }; + let _ = app.update(Message::Page(Page::Text)); + let old_indent = Message::SourceIndent(app.source_symbol_context(), false); + let old_drawer = Message::TextWorkspace(text_workspace::Event::ShowDrawer( + app.text_drawer_context(), + text_workspace::DrawerTab::Problems, + )); + let old_command = app.text_command_message(text_workspace::Command::ShowStatus); + let _ = app.update(Message::Diagram(Choice { + id: diagram, + label: "Saved".into(), + })); + let context = app.scenario_context().unwrap(); + assert_ne!(context.visit, previous.visit); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + context, + Size::new(900., 600.), + ))); + let (event, start, _) = begin(&mut app, Target::Lane(lane)); + move_to(&mut app, event, start + Vector::new(24., 0.)); + let accepted = app.document.snapshot(); + let source = app.source_workspace.bundle(); + let source_version = app.source_version; + let camera = app.camera_boundary(); + let selected = app.selected; + let events = [ + old_indent, + old_drawer, + old_command, + Message::ScenarioCanvas(CanvasEvent::Shelf( + previous, + behavior::scenario::Shelf::Selection, + )), + Message::ScenarioCanvas(CanvasEvent::Properties(previous, lane)), + ]; + for rejected in events { + let _ = app.update(rejected); + assert_eq!(app.scenario_context(), Some(context)); + assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token())); + assert_eq!(app.behavior.scenario.spacing_stamp(), Some(event.stamp())); + assert!(app.camera_boundary() == camera); + assert_eq!(app.selected, selected); + assert_eq!(app.source_version, source_version); + assert_eq!(app.source_workspace.bundle(), source); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert_eq!(app.project(), &accepted.decoded.project); + assert!(app.text_workspace.drawer().is_none()); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } + finish(&mut app, event, start + Vector::new(24., 0.)); + assert!( + app.document.can_undo(), + "the current gesture remains usable" + ); +} +fn begin(app: &mut App, target: Target) -> (Event, Point, f32) { + let event = packet( + app.project(), + &app.behavior.scenario, + app.scenario_context().unwrap(), + app.camera_epoch, + target, + ); + let Event::Begin { + press_screen, + scale, + .. + } = event + else { + unreachable!() + }; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event))); + assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token())); + (event, press_screen, scale) +} +fn send(app: &mut App, event: Event) { + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event))); +} +fn move_to(app: &mut App, begin: Event, screen: Point) { + send( + app, + Event::Move { + stamp: begin.stamp(), + token: begin.token(), + screen, + }, + ); +} +fn finish(app: &mut App, begin: Event, screen: Point) { + send( + app, + Event::Finish { + stamp: begin.stamp(), + token: begin.token(), + screen, + }, + ); +} + +#[test] +fn same_batch_preview_is_immutable_and_one_commit_undo_redo_has_exact_source_parity() { + let (mut app, lane, _) = setup(); + let original = app.document.snapshot(); + let source = app.source_workspace.bundle(); + let selected = app.selected; + let epoch = app.canvas_epoch; + let (event, start, scale) = begin(&mut app, Target::Lane(lane)); + move_to(&mut app, event, start + Vector::new(40., 300.)); + assert_eq!(app.selected, selected); + assert_eq!(app.canvas_epoch, epoch); + assert_eq!(app.project(), &original.decoded.project); + assert_eq!(app.source_workspace.bundle(), source); + assert!(!app.document.can_undo() && !app.document.can_redo()); + finish(&mut app, event, start + Vector::new(40., 300.)); + assert_eq!(app.selected, lane); + assert!(app.document.can_undo() && !app.document.can_redo()); + let accepted = app.document.snapshot(); + assert_ne!(accepted.bundle, original.bundle); + let expected = behavior::scenario::spacing::controller::tests::expected( + &original.decoded.project, + match event.stamp().context.representation { + behavior::scenario_layout::Representation::Saved { diagram } => diagram, + _ => unreachable!(), + }, + Target::Lane(lane), + 40. / scale, + ); + assert_eq!(app.project(), &expected); + let _ = app.update(Message::ShortcutUndo(false)); + assert_eq!(app.project(), &original.decoded.project); + assert_eq!(app.document.bundle(), &original.bundle); + assert!(!app.document.can_undo() && app.document.can_redo()); + let _ = app.update(Message::ShortcutUndo(true)); + assert_eq!(app.project(), &accepted.decoded.project); + assert_eq!(app.document.bundle(), &accepted.bundle); +} + +#[test] +fn click_selects_but_crossed_return_and_cancel_preserve_selection_camera_and_history() { + let (mut app, lane, message) = setup(); + let original = app.document.snapshot(); + let camera = app.behavior.scenario.camera_boundary(); + let (event, start, _) = begin(&mut app, Target::Message(message)); + finish(&mut app, event, start + Vector::new(30., 2.)); + assert_eq!(app.selected, message); + assert!(camera == app.behavior.scenario.camera_boundary()); + for cancel in [false, true] { + let (event, start, _) = begin(&mut app, Target::Lane(lane)); + move_to(&mut app, event, start + Vector::new(30., 0.)); + if cancel { + send( + &mut app, + Event::Cancel { + stamp: event.stamp(), + token: event.token(), + }, + ); + } else { + finish(&mut app, event, start); + } + assert_eq!(app.selected, message); + assert!(camera == app.behavior.scenario.camera_boundary()); + assert_eq!(app.document.bundle(), &original.bundle); + assert_eq!(app.project(), &original.decoded.project); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[test] +fn stale_finish_after_new_view_and_forged_token_are_inert_before_reveal_reconciliation() { + let (mut app, lane, _) = setup(); + let (event, start, _) = begin(&mut app, Target::Lane(lane)); + let held_epoch = app.canvas_epoch; + send( + &mut app, + Event::Finish { + stamp: event.stamp(), + token: Id::new_v4(), + screen: start + Vector::new(30., 0.), + }, + ); + assert_eq!(app.canvas_epoch, held_epoch); + assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token())); + let owner = event.stamp().context.scenario; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation( + event.stamp().context, + behavior::scenario_layout::Representation::Derived { owner }, + ))); + let current = app.scenario_context(); + let epoch = app.canvas_epoch; + let original = app.document.snapshot(); + finish(&mut app, event, start + Vector::new(50., 0.)); + assert_eq!(app.scenario_context(), current); + assert_eq!(app.canvas_epoch, epoch); + assert!(app.behavior.scenario.spacing_token().is_none()); + assert_eq!(app.document.bundle(), &original.bundle); +} + +#[test] +fn source_write_failure_cannot_commit_candidate_or_materialize_camera() { + let (mut app, lane, _) = setup(); + let bundle = app.document.bundle().clone(); + let mut limits = oa_core::source_project::ProjectLimits::default(); + limits.model.max_source_bytes = bundle.files.values().map(String::len).sum(); + app.document = Document::from_bundle(bundle, limits).unwrap(); + app.sync(); + let original = app.document.snapshot(); + let selected = app.selected; + let camera = app.behavior.scenario.camera_boundary(); + let (event, start, _) = begin(&mut app, Target::Lane(lane)); + move_to(&mut app, event, start + Vector::new(40., 0.)); + finish(&mut app, event, start + Vector::new(40., 0.)); + assert!( + app.status.starts_with("Spacing was not applied:"), + "{}", + app.status + ); + assert_eq!(app.selected, selected); + assert!(camera == app.behavior.scenario.camera_boundary()); + assert_eq!(app.project(), &original.decoded.project); + assert_eq!(app.document.bundle(), &original.bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); + assert!(app.behavior.scenario.spacing_token().is_none()); +} + +#[test] +fn pending_semantic_draft_and_source_edit_cancel_and_reject_held_finish() { + for source in [false, true] { + let (mut app, lane, _) = setup(); + let original = app.document.snapshot(); + let selected = app.selected; + let (event, start, _) = begin(&mut app, Target::Lane(lane)); + move_to(&mut app, event, start + Vector::new(30., 0.)); + if source { + let _ = app.update(Message::SourceEdit(text_editor::Action::Edit( + text_editor::Edit::Insert(' '), + ))); + assert!(app.source_dirty()); + } else { + let _ = app.update(Message::Behavior(behavior::Event::Scenario( + behavior::scenario::Event::Start( + event.stamp().context.scenario, + behavior::scenario::Tool::Lifeline, + ), + ))); + assert!(app.behavior.has_pending()); + } + let draft = app.source_workspace.bundle(); + assert!(app.behavior.scenario.spacing_token().is_none()); + finish(&mut app, event, start + Vector::new(30., 0.)); + assert_eq!(app.project(), &original.decoded.project); + assert_eq!(app.document.bundle(), &original.bundle); + assert_eq!(app.source_workspace.bundle(), draft); + assert_eq!(app.selected, selected); + assert!(!app.document.can_undo()); + } +} + +#[test] +fn stale_finish_cannot_remove_new_same_view_deferred_reveal() { + let (mut app, lane, message) = setup(); + let (event, start, _) = begin(&mut app, Target::Lane(lane)); + let behavior::scenario_layout::Representation::Saved { diagram } = + event.stamp().context.representation + else { + unreachable!() + }; + let _ = app.update(Message::RevealInDiagram(diagram, message)); + assert!(app.pending_diagram_reveal.is_some()); + let epoch = app.canvas_epoch; + let selected = app.selected; + let visit = app.pending_scenario_reveal_visit; + let original = app.document.snapshot(); + finish(&mut app, event, start + Vector::new(50., 0.)); + assert_eq!(app.canvas_epoch, epoch); + assert_eq!(app.pending_scenario_reveal_visit, visit); + assert!(app.pending_diagram_reveal.is_some()); + assert_eq!(app.selected, selected); + assert_eq!(app.document.bundle(), &original.bundle); + assert!(!app.document.can_undo()); +} + +#[test] +fn modal_open_cancels_preview_and_captured_begin_stays_retired_after_close() { + let (mut app, lane, _) = setup(); + let original = app.document.snapshot(); + let camera = app.behavior.scenario.camera_boundary(); + let (event, start, _) = begin(&mut app, Target::Lane(lane)); + move_to(&mut app, event, start + Vector::new(30., 0.)); + let _ = app.update(Message::Delete); + assert!(app.delete_pending); + assert!(app.behavior.scenario.spacing_token().is_none()); + finish(&mut app, event, start + Vector::new(30., 0.)); + let _ = app.update(Message::CancelDelete); + send(&mut app, event); + assert!(app.behavior.scenario.spacing_token().is_none()); + assert_eq!(app.project(), &original.decoded.project); + assert_eq!(app.document.bundle(), &original.bundle); + assert!(camera == app.behavior.scenario.camera_boundary()); + assert!(!app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/app/state_diagram.rs b/crates/oa-desktop/src/app/state_diagram.rs index 090de012..38a0e656 100644 --- a/crates/oa-desktop/src/app/state_diagram.rs +++ b/crates/oa-desktop/src/app/state_diagram.rs @@ -24,6 +24,7 @@ impl App { if self.behavior.has_pending() { return; } + self.behavior.scenario.forget_view(); self.layer = layer; self.page = Page::Behavior; self.behavior.active_owner = Some(machine); diff --git a/crates/oa-desktop/src/app/transition_context_tests.rs b/crates/oa-desktop/src/app/transition_context_tests.rs index 65425248..f02c014e 100644 --- a/crates/oa-desktop/src/app/transition_context_tests.rs +++ b/crates/oa-desktop/src/app/transition_context_tests.rs @@ -49,11 +49,28 @@ fn fixture(layer: Layer, kind: Kind) -> (App, Id, Id, Id) { p.diagrams.get_mut(&view).unwrap().name = "Unrelated architecture".into(); p.place(view, reused, Position { x: 873., y: 421. }); let mut app = App::with_project(p); - app.layer = layer; - app.page = Page::Behavior; - app.selected = root; - app.behavior.active_owner = Some(root); - app.refresh_draft(); + let accepted = app.document.snapshot(); + // Enter through the actual navigation path: assigning Page/owner alone does + // not create the representation and live visit required by canvas events. + send(&mut app, Message::Select(root)); + send(&mut app, Message::Page(Page::Behavior)); + assert_eq!(app.layer, layer); + assert_eq!(app.page, Page::Behavior); + assert_eq!(app.selected, root); + assert_eq!(app.behavior.active_owner, Some(root)); + if kind == Kind::Scenario { + let context = app.scenario_context().unwrap(); + assert_eq!(context.document, app.canvas_document); + assert_eq!(context.scenario, root); + assert_eq!( + context.representation, + crate::behavior::scenario_layout::Representation::Derived { owner: root } + ); + assert!(!context.visit.is_nil()); + } + assert!(Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert_eq!(app.source_workspace.bundle(), accepted.bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); (app, root, work, reused) } diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index e3cf8a0d..29f3225d 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -21,6 +21,11 @@ pub(crate) mod creation; mod form_session; pub(crate) mod scenario; mod scenario_activation; +#[cfg(test)] +mod scenario_hit_tests; +// Pure saved-geometry bridge; workspace integration is a separate checkpoint. +#[allow(dead_code)] +pub(crate) mod scenario_layout; mod scenario_lifecycle; pub(crate) mod source_draft; pub(crate) mod state_canvas; @@ -59,9 +64,18 @@ mod scenario_scroll { process: bool, restore: Option, } - type Report = Option<(bool, Id, Id, Vector)>; + type Identity = (Id, Id, scenario_layout::Representation, Id); + fn identity(context: scenario::Context) -> Identity { + ( + context.document, + context.scenario, + context.representation, + context.visit, + ) + } + type Report = Option<(bool, Identity, Vector)>; struct ObservedScroll { - identity: (Id, Id), + identity: Identity, report: Report, restored: Option, } @@ -112,8 +126,12 @@ mod scenario_scroll { ) { let restore = self.restore.filter(|request| { !self.process - && (request.document, request.owner) - == (self.context.document, self.context.scenario) + && ( + request.document, + request.owner, + request.representation, + request.visit, + ) == identity(self.context) && tree.state.downcast_ref::().restored != Some(request.token) }); let receipt = restore.and_then(|request| { @@ -137,12 +155,7 @@ mod scenario_scroll { &mut offset, ); if let Some(offset) = offset.0 { - let next = Some(( - self.process, - self.context.document, - self.context.scenario, - offset, - )); + let next = Some((self.process, identity(self.context), offset)); let state = tree.state.downcast_mut::(); if let Some(token) = receipt { state.restored = Some(token); @@ -186,7 +199,7 @@ mod scenario_scroll { } fn state(&self) -> widget::tree::State { widget::tree::State::new(ObservedScroll { - identity: (self.context.document, self.context.scenario), + identity: identity(self.context), report: None, restored: None, }) @@ -195,9 +208,7 @@ mod scenario_scroll { vec![widget::Tree::new(self.content.as_widget())] } fn diff(&self, tree: &mut widget::Tree) { - if tree.state.downcast_ref::().identity - != (self.context.document, self.context.scenario) - { + if tree.state.downcast_ref::().identity != identity(self.context) { // A new owner/document needs fresh real scroll state, not merely // cleared mirrored offsets. Edits and tool/form changes retain it. tree.state = self.state(); @@ -486,17 +497,25 @@ mod scenario_scroll { } #[test] fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() { - for mismatch in 0..3 { + for mismatch in 0..5 { let context = scenario::direct_tests::context(Id::new_v4()); let mut request = scenario::ScrollRestore { document: context.document, owner: context.scenario, + representation: context.representation, + visit: context.visit, token: Id::new_v4(), offset: Vector::new(0., 180.), }; match mismatch { 0 => request.document = Id::new_v4(), 1 => request.owner = Id::new_v4(), + 3 => { + request.representation = scenario_layout::Representation::Saved { + diagram: Id::new_v4(), + } + } + 4 => request.visit = Id::new_v4(), _ => {} } let mut view = observer(600., context); @@ -583,7 +602,7 @@ mod scenario_scroll { use widget::operation::scrollable::{AbsoluteOffset, scroll_to}; for process in [false, true] { let area = scenario::ScrollArea::Page; - for change in 0..4 { + for change in 0..6 { let context = scenario::direct_tests::context(Id::new_v4()); let mut view = observer(600., context); view.process = process; @@ -612,6 +631,12 @@ mod scenario_scroll { next.revision += 1; next.epoch += 5; } + 4 => { + next.representation = scenario_layout::Representation::Saved { + diagram: Id::new_v4(), + } + } + 5 => next.visit = Id::new_v4(), _ => {} } view = observer(if change == 3 { 800. } else { 600. }, next); @@ -659,7 +684,7 @@ mod scenario_scroll { .collect::>(); assert_eq!( actual, - vec![if change < 2 { + vec![if !(2..4).contains(&change) { Vector::ZERO } else { Vector::new(0., 180.) @@ -973,6 +998,19 @@ pub fn view(app: &App) -> Element<'_, Message> { return container(out).padding(16).width(Fill).height(Fill).into(); } } + // A requested saved view remains the destination even after its model is removed. + // The shared display adapter reports unavailable instead of choosing another behavior. + if matches!( + app.behavior.scenario.representation(), + Some(scenario_layout::Representation::Saved { .. }) + ) && let Some(owner) = app.behavior.scenario.owner() + && !p + .elements + .get(&owner) + .is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == app.layer) + { + return scenario::view(app, owner, column![].spacing(6)); + } let choices = p .elements .values() @@ -984,7 +1022,14 @@ pub fn view(app: &App) -> Element<'_, Message> { label: format!("{} · {}", e.kind(), e.name), }) .collect::>(); - let owner = selected_owner(app); + let owner = if matches!( + app.behavior.scenario.representation(), + Some(scenario_layout::Representation::Saved { .. }) + ) { + app.behavior.scenario.owner() + } else { + selected_owner(app) + }; let selected = owner .and_then(|id| choices.iter().find(|c| c.id == id)) .or(choices.first()) @@ -1118,8 +1163,27 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool { false } +/// Identifies the drawn primitive that won selection, not an authoring permission. +/// In particular an activation and its message can share an occurrence UUID. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SceneHitRole { + LifelineHeader, + MessageBody, + MessageLabel, + Activation, + LifecycleMarker, + LifelineGuide, + Box, + OtherLine, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SceneHit { + id: Id, + role: SceneHitRole, +} struct BoxItem { id: Id, + role: SceneHitRole, rect: Rectangle, title: String, detail: String, @@ -1152,6 +1216,7 @@ fn box_text_clip( struct Line { id: Id, + role: SceneHitRole, points: Vec, label: String, label_at: Point, @@ -1172,12 +1237,17 @@ struct Scene { boxes: Vec, lines: Vec, size: Size, + layout_origin: Point, selected: Id, scale: f32, camera_pan: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, + spacing_stamp: Option, + coordinate_input: bool, pending_placement: Option<(scenario::Slot, Option)>, lanes: BTreeMap, + // Exact render coordinates include unassigned lanes; do not derive from cards. + render_lanes: BTreeMap, slots: Vec, rows: BTreeMap, activations: Vec, @@ -1201,6 +1271,10 @@ struct SequenceInput { } #[derive(Clone, Copy)] enum SequencePress { + Spacing { + stamp: scenario::spacing::Stamp, + token: Id, + }, Occurrence { tool: scenario::Tool, slot: scenario::Slot, @@ -1221,6 +1295,19 @@ enum SequencePress { }, } impl SequenceInput { + fn cancel_spacing(&mut self) -> Option> { + let cancel = match self.press { + Some(SequencePress::Spacing { stamp, token }) => Some( + Action::publish(Message::ScenarioCanvas(scenario::CanvasEvent::Spacing( + scenario::spacing::Event::Cancel { stamp, token }, + ))) + .and_capture(), + ), + _ => None, + }; + self.cancel(); + cancel + } fn cancel(&mut self) { self.source = None; self.press = None; @@ -1232,17 +1319,31 @@ impl Scene { && (self.boxes.iter().any(|item| item.id == id) || self.lines.iter().any(|item| item.id == id)) } + // The integrated removal guard also needs the derived semantic hit set. fn sequence(p: &Project, owner: Id, selected: Id) -> Self { + Self::sequence_geometry(p, owner, selected, None, None) + } + fn sequence_geometry( + p: &Project, + owner: Id, + selected: Id, + lane_override: Option<&BTreeMap>, + plan: Option<&scenario_layout::Plan>, + ) -> Self { let mut scene = Self { scale: 1., camera_pan: iced::Vector::ZERO, boxes: vec![], lines: vec![], size: Size::new(700., 200.), + layout_origin: Point::ORIGIN, selected, interaction: None, + spacing_stamp: None, + coordinate_input: true, pending_placement: None, lanes: BTreeMap::new(), + render_lanes: BTreeMap::new(), slots: vec![], rows: BTreeMap::new(), activations: vec![], @@ -1313,12 +1414,32 @@ impl Scene { }) .collect(); } - let lane_x = lanes + let mut lane_x = lanes .iter() .enumerate() .map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.)) .collect::>(); + if let Some(values) = lane_override { + for (id, x) in values { + if let Some(lane) = lane_x.get_mut(id) { + *lane = *x; + } + } + } + scene.render_lanes = lane_x.clone(); scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.); + let layout_left = if lane_override.is_some() { + lane_x.values().fold(0_f32, |left, x| left.min(*x - 160.)) + } else { + 0. + }; + scene.layout_origin.x = layout_left; + if lane_override.is_some() { + scene.size.width = lane_x + .values() + .fold(scene.size.width, |right, x| right.max(*x + 240.)) + - layout_left; + } if scenario { scene.lanes = lane_x .iter() @@ -1334,8 +1455,26 @@ impl Scene { .collect(); } let mut y = if scenario { 148. } else { 120. }; - scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0); - scene.size.height = (y + 45.).max(210.); + scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0, plan); + if layout_left != 0. { + for item in &mut scene.boxes { + let body = scenario_layout::occurrence(p, item.id); + if matches!( + body, + Some( + OccurrenceBody::Branch { .. } + | OccurrenceBody::ScenarioUse { .. } + | OccurrenceBody::Control { .. } + | OccurrenceBody::SequenceReference { .. } + | OccurrenceBody::Exchange { .. } + ) + ) || (plan.is_none() && matches!(body, Some(OccurrenceBody::Fragment { .. }))) + { + item.rect.x += layout_left; + } + } + } + scene.size.height = plan.map_or((y + 45.).max(210.), |plan| plan.height); if scenario { scene.lifecycle = scenario_lifecycle::render::Rendering::new( p, @@ -1346,6 +1485,9 @@ impl Scene { scene.size.height, ); scene.lifecycle.align_messages(&mut scene.lines); + if let Some(plan) = plan { + plan.align_message_anchors(&mut scene.lines); + } scene.layout_activations(p, &lane_x); } for (id, name, detail) in lanes { @@ -1355,6 +1497,7 @@ impl Scene { 0, Line { id, + role: SceneHitRole::LifelineGuide, points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)], label: String::new(), label_at: Point::ORIGIN, @@ -1364,6 +1507,11 @@ impl Scene { } scene.boxes.push(BoxItem { id, + role: if scenario { + SceneHitRole::LifelineHeader + } else { + SceneHitRole::Box + }, rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)), title: name, detail, @@ -1372,6 +1520,7 @@ impl Scene { } scene } + #[allow(clippy::too_many_arguments)] // Explicit complete plan replaces ambient geometry state. fn occurrences( &mut self, p: &Project, @@ -1380,6 +1529,7 @@ impl Scene { lanes: &BTreeMap, y: &mut f32, depth: usize, + plan: Option<&scenario_layout::Plan>, ) { let steps = p .elements @@ -1419,6 +1569,9 @@ impl Scene { } #[cfg(test)] scenario_lifecycle::render::apply_test_row_offset(*id, y); + if let Some(plan) = plan { + *y = plan.anchors[id].y; + } if ordered { let slot = scenario::Slot { parent: owner, @@ -1435,7 +1588,7 @@ impl Scene { OccurrenceBody::Fragment { operator, .. } => { let top = *y; *y += 55.; - self.occurrences(p, *id, scenario, lanes, y, depth + 1); + self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan); let rect = Rectangle::new( Point::new(18. + depth as f32 * 10., top), Size::new( @@ -1443,10 +1596,12 @@ impl Scene { (*y - top).max(95.), ), ); + let rect = plan.map_or(rect, |plan| plan.frames[id]); self.boxes.insert( 0, BoxItem { id: *id, + role: SceneHitRole::Box, rect, title: format!("{operator:?} · {}", e.name), detail: String::new(), @@ -1458,6 +1613,7 @@ impl Scene { OccurrenceBody::Branch { guard, .. } => { self.boxes.push(BoxItem { id: *id, + role: SceneHitRole::Box, rect: Rectangle::new( Point::new(36. + depth as f32 * 10., *y), Size::new(self.size.width - 85. - depth as f32 * 20., 43.), @@ -1474,7 +1630,7 @@ impl Scene { marker: Some(Kind::Occurrence), }); *y += if scenario { 90. } else { 62. }; - self.occurrences(p, *id, scenario, lanes, y, depth + 1); + self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan); } OccurrenceBody::Exchange { exchange, @@ -1502,7 +1658,7 @@ impl Scene { source.and_then(|id| lanes.get(&id)), target.and_then(|id| lanes.get(&id)), ) { - let points = if a == b { + let points = if source == target { vec![ Point::new(*a, *y), Point::new(a + 75., *y), @@ -1541,6 +1697,7 @@ impl Scene { } self.lines.push(Line { id: *id, + role: SceneHitRole::MessageBody, points, label: format!("{}. {kind}{}", index + 1, message_label), label_at: Point::new(a.min(*b) + 12., *y - 26.), @@ -1549,6 +1706,7 @@ impl Scene { } else { self.boxes.push(BoxItem { id: *id, + role: SceneHitRole::Box, rect: Rectangle::new( Point::new(65., *y - 20.), Size::new(self.size.width - 130., 65.), @@ -1571,6 +1729,7 @@ impl Scene { .unwrap_or(180.); self.boxes.push(BoxItem { id: *id, + role: SceneHitRole::Box, rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)), title: e.name.clone(), detail: action @@ -1583,6 +1742,7 @@ impl Scene { OccurrenceBody::Control { kind } => { self.boxes.push(BoxItem { id: *id, + role: SceneHitRole::Box, rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)), title: format!("{kind:?} · {}", e.name), detail: "Contextual process control".into(), @@ -1593,6 +1753,7 @@ impl Scene { OccurrenceBody::SequenceReference { sequence } => { self.boxes.push(BoxItem { id: *id, + role: SceneHitRole::Box, rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)), title: e.name.clone(), detail: sequence @@ -1605,6 +1766,7 @@ impl Scene { OccurrenceBody::ScenarioUse { scenario, bindings } => { self.boxes.push(BoxItem { id: *id, + role: SceneHitRole::Box, rect: Rectangle::new( Point::new(65., *y - 20.), Size::new(self.size.width - 130., 70.), @@ -1627,6 +1789,10 @@ impl Scene { } if ordered { self.sequence_bounds.insert(owner, (sequence_top, *y - 42.)); + if let Some(plan) = plan { + self.sequence_bounds.insert(owner, plan.scopes[&owner]); + *y = plan.scopes[&owner].1 + 42.; + } let slot = scenario::Slot { parent: owner, before: None, @@ -1666,11 +1832,21 @@ impl Scene { )) } fn hit(&self, point: Point) -> Option { + self.hit_item(point).map(|hit| hit.id) + } + + /// Preserve the semantic selection order and tolerance while retaining the + /// primitive's identity. Future spacing admission must also validate the + /// saved view, owner and core occurrence kind; a hit alone grants no edit. + fn hit_item(&self, point: Point) -> Option { if !point.x.is_finite() || !point.y.is_finite() { return None; } if let Some(id) = self.lifecycle.marker_at(point) { - return Some(id); + return Some(SceneHit { + id, + role: SceneHitRole::LifecycleMarker, + }); } self.boxes .iter() @@ -1680,7 +1856,10 @@ impl Scene { && b.rect.contains(point) && (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.) }) - .map(|b| b.id) + .map(|b| SceneHit { + id: b.id, + role: b.role, + }) .or_else(|| { self.lines .iter() @@ -1690,26 +1869,51 @@ impl Scene { crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5 }) }) - .map(|line| line.id) + .map(|line| SceneHit { + id: line.id, + role: line.role, + }) }) .or_else(|| { self.activations .iter() .rev() .find(|b| b.rect.contains(point)) - .map(|b| b.call) + .map(|b| SceneHit { + id: b.call, + role: SceneHitRole::Activation, + }) }) .or_else(|| { self.lines .iter() .find(|line| !line.label.is_empty() && line.label_bounds().contains(point)) - .map(|line| line.id) + .map(|line| SceneHit { + id: line.id, + role: if line.role == SceneHitRole::MessageBody { + SceneHitRole::MessageLabel + } else { + SceneHitRole::OtherLine + }, + }) }) // The generous message tolerance must not steal the exposed // shoulder of another call's execution when the view is zoomed out. .or_else(|| self.near_sequence_item(point)) - .or_else(|| self.lifecycle.lane_at(point, self.scale)) - .or_else(|| self.lane_at(point)) + .or_else(|| { + self.lifecycle + .lane_at(point, self.scale) + .map(|id| SceneHit { + id, + role: SceneHitRole::LifelineGuide, + }) + }) + .or_else(|| { + self.lane_at(point).map(|id| SceneHit { + id, + role: SceneHitRole::LifelineGuide, + }) + }) .or_else(|| { self.lines .iter() @@ -1720,11 +1924,14 @@ impl Scene { < 8. / self.scale }) }) - .map(|line| line.id) + .map(|line| SceneHit { + id: line.id, + role: line.role, + }) }) } - fn near_sequence_item(&self, point: Point) -> Option { + fn near_sequence_item(&self, point: Point) -> Option { if !self.scale.is_finite() || self.scale <= 0. { return None; } @@ -1738,17 +1945,32 @@ impl Scene { .windows(2) .map(|p| crate::diagram::distance_to_segment(point, p[0], p[1])) .fold(f32::INFINITY, f32::min); - (line.id, distance) + ( + SceneHit { + id: line.id, + role: line.role, + }, + distance, + ) }); // Compare broad message and lifecycle-marker tolerance together after // exact shapes, so dense minimum-Fit rows choose the nearer item. self.lifecycle .nearest_marker(point) + .map(|(id, distance)| { + ( + SceneHit { + id, + role: SceneHitRole::LifecycleMarker, + }, + distance, + ) + }) .into_iter() .chain(messages) .filter(|(_, distance)| *distance <= 8. / self.scale) - .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0))) - .map(|(id, _)| id) + .min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.id.cmp(&b.0.id))) + .map(|(hit, _)| hit) } } impl canvas::Program for Scene { @@ -1776,15 +1998,33 @@ impl canvas::Program for Scene { // captured insertion slot belongs to the previous view, even if the // pointer itself did not move and the semantic context is unchanged. if state.bounds.is_some_and(|previous| previous != bounds) { - state.cancel(); + let cancelled = state.cancel_spacing(); state.pointer = None; + state.bounds = Some(bounds); + if cancelled.is_some() { + return cancelled; + } } state.bounds = Some(bounds); - if !editable { - state.cancel(); + if (!editable || !self.coordinate_input) + && let Some(cancelled) = state.cancel_spacing() + { + return Some(cancelled); } // The viewport masks input outside its visible canvas bounds. let visible = cursor.position().is_some(); + if !visible + && matches!(state.press, Some(SequencePress::Spacing { .. })) + && matches!( + event, + canvas::Event::Mouse( + mouse::Event::CursorMoved { .. } + | mouse::Event::ButtonReleased(mouse::Button::Left) + ) + ) + { + return state.cancel_spacing(); + } if !visible && matches!( event, @@ -1803,7 +2043,7 @@ impl canvas::Program for Scene { | SequencePress::Reorder { start, moved, .. } => { *moved |= position.distance(*start) >= 3. } - SequencePress::Occurrence { .. } => {} + SequencePress::Occurrence { .. } | SequencePress::Spacing { .. } => {} } } mouse::Cursor::Available(*position) @@ -1812,9 +2052,9 @@ impl canvas::Program for Scene { | canvas::Event::Window(iced::window::Event::Resized(_)) | canvas::Event::Mouse(mouse::Event::CursorLeft) | canvas::Event::Window(iced::window::Event::Unfocused) => { - state.cancel(); + let cancelled = state.cancel_spacing(); state.pointer = None; - return Some(Action::request_redraw()); + return cancelled.or_else(|| Some(Action::request_redraw())); } canvas::Event::Mouse( mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_), @@ -1850,8 +2090,9 @@ impl canvas::Program for Scene { key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape), .. }) if state.source.is_some() || state.press.is_some() => { - state.cancel(); - return Some(Action::request_redraw().and_capture()); + return state + .cancel_spacing() + .or_else(|| Some(Action::request_redraw().and_capture())); } _ => cursor, }; @@ -1871,6 +2112,14 @@ impl canvas::Program for Scene { if !editable { return None; } + if !self.coordinate_input { + return self.hit(point).map(|id| { + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select( + context, id, + ))) + .and_capture() + }); + } if let CanvasTool::Message(_) = tool { let first = state.source.is_none(); let (source, slot) = state.source.or_else(|| { @@ -1897,6 +2146,48 @@ impl canvas::Program for Scene { }); return Some(Action::request_redraw().and_capture()); } + if matches!( + context.representation, + scenario_layout::Representation::Saved { .. } + ) { + let hit = self.hit_item(point)?; + let target = match hit.role { + SceneHitRole::LifelineHeader => { + Some(scenario::spacing::Target::Lane(hit.id)) + } + SceneHitRole::MessageBody | SceneHitRole::MessageLabel => { + Some(scenario::spacing::Target::Message(hit.id)) + } + _ => None, + }; + if let Some((target, stamp)) = target.zip(self.spacing_stamp) + && stamp.context == context + { + let token = Id::new_v4(); + state.press = Some(SequencePress::Spacing { stamp, token }); + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing( + scenario::spacing::Event::Begin { + stamp, + token, + target, + press_screen: cursor.position()?, + canvas_bounds: bounds, + scale: self.scale, + pan: self.camera_pan, + }, + ))) + .and_capture(), + ); + } + // Saved spacing never silently changes the shared Scenario order. + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Select( + context, hit.id, + ))) + .and_capture(), + ); + } if let Some(occurrence) = self .hit(point) .filter(|id| self.rows.contains_key(id)) @@ -1926,6 +2217,22 @@ impl canvas::Program for Scene { let press = state.press.take()?; let context = context?; match press { + SequencePress::Spacing { stamp, token } => { + state.source = None; + let event = if world.is_some() { + scenario::spacing::Event::Finish { + stamp, + token, + screen: cursor.position()?, + } + } else { + scenario::spacing::Event::Cancel { stamp, token } + }; + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(event))) + .and_capture(), + ); + } SequencePress::Occurrence { tool: captured, slot, @@ -2017,6 +2324,18 @@ impl canvas::Program for Scene { Some(Action::request_redraw().and_capture()) } canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => { + if let Some(SequencePress::Spacing { stamp, token }) = state.press { + return Some( + Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing( + scenario::spacing::Event::Move { + stamp, + token, + screen: cursor.position()?, + }, + ))) + .and_capture(), + ); + } Some(Action::request_redraw()) } _ => None, @@ -2198,7 +2517,10 @@ impl canvas::Program for Scene { self.lifecycle .draw_markers(&mut f, self.selected, self.scale, self.camera_pan); if let Some((context, tool, editable)) = self.interaction { - let active = editable && state.context == Some(context) && state.tool == tool; + let active = editable + && self.coordinate_input + && state.context == Some(context) + && state.tool == tool; let point = cursor.position_in(bounds).map(|p| { Point::new( (p.x - self.camera_pan.x) / self.scale, @@ -2214,7 +2536,12 @@ impl canvas::Program for Scene { } }); if editable + && self.coordinate_input && tool == scenario::CanvasTool::Select + && matches!( + context.representation, + scenario_layout::Representation::Derived { .. } + ) && let Some(handle) = self.reorder_handle() { f.fill_rectangle(handle.position(), handle.size(), style::TEAL); diff --git a/crates/oa-desktop/src/behavior/canvas_workspace.rs b/crates/oa-desktop/src/behavior/canvas_workspace.rs index 9c4af104..6982cb35 100644 --- a/crates/oa-desktop/src/behavior/canvas_workspace.rs +++ b/crates/oa-desktop/src/behavior/canvas_workspace.rs @@ -50,7 +50,7 @@ pub fn controls<'a>( control("Details", "Details", Some(details)), control("Fit", "Fit", Some(zoom(None))), control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))), - text(format!("{:.0}%", scale * 100.)).size(12), + text(zoom_label(scale)).size(12), control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))), control("ActualSize", "100%", Some(zoom(Some(1.)))), ] @@ -65,6 +65,7 @@ pub fn controls<'a>( #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Scope { + pub(crate) scenario_view: Option<(super::scenario_layout::Representation, Id)>, pub document: Id, pub owner: Id, pub revision: u64, @@ -76,6 +77,7 @@ impl Scope { Some(Self { document: context.document, owner: context.scenario, + scenario_view: Some((context.representation, context.visit)), revision: context.revision, epoch: app.camera_epoch, }) @@ -84,6 +86,7 @@ impl Scope { Some(Self { document: context.document, owner: context.owner, + scenario_view: None, revision: context.revision, epoch: app.camera_epoch, }) @@ -103,8 +106,8 @@ impl Scope { pub fn control_id(self, name: &str) -> iced::advanced::widget::Id { iced::advanced::widget::Id::from(format!( - "behavior-control:{}:{}:{}:{name}", - self.document, self.owner, self.revision + "behavior-control:{}:{}:{}:{:?}:{name}", + self.document, self.owner, self.revision, self.scenario_view )) } } @@ -137,6 +140,20 @@ pub enum Event { pub use focus::{focus_task, root as focus_root}; /// Fold relative input in event order. The base scale is used only on leaving Fit. pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: Event) -> bool { + change_with_minimum(pan, zoom, fit, event, 0.05) +} + +/// Scenario extents can require scales below the topology canvas's 5% floor. +pub(super) fn change_with_minimum( + pan: &mut Vector, + zoom: &mut Option, + fit: &mut bool, + event: Event, + minimum: f32, +) -> bool { + if !minimum.is_finite() || minimum <= 0. || minimum > 3. { + return false; + } match event { Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => { let next = *pan + delta; @@ -156,7 +173,10 @@ pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: E && at.y.is_finite() => { let old = if *fit { base } else { zoom.unwrap_or(base) }; - let next = (old * factor).clamp(0.05, 3.); + if !old.is_finite() || old <= 0. { + return false; + } + let next = (old * factor).clamp(minimum, 3.); if old == next { return false; } @@ -174,6 +194,51 @@ pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: E } } +fn zoom_label(scale: f32) -> String { + let percent = f64::from(scale) * 100.; + if !percent.is_finite() || percent <= 0. { + return "—".into(); + } + if percent >= 1. { + format!("{percent:.0}%") + } else if percent >= 0.01 { + format!("{percent:.3}%") + } else { + format!("{percent:.2e}%") + } +} + +#[cfg(test)] +mod camera_policy_tests { + use super::*; + + #[test] + fn topology_retains_five_percent_floor() { + let (mut pan, mut zoom, mut fit) = (Vector::ZERO, Some(0.06), false); + assert!(change( + &mut pan, + &mut zoom, + &mut fit, + Event::Zoom { + factor: 0.1, + at: Point::ORIGIN, + base: 0.06, + } + )); + assert_eq!(zoom, Some(0.05)); + } + + #[test] + fn tiny_zoom_label_is_nonzero_and_changes_with_zoom() { + assert_eq!(zoom_label(1.), "100%"); + assert_eq!(zoom_label(0.05), "5%"); + assert_eq!(zoom_label(0.00034193), "0.034%"); + assert_eq!(zoom_label(0.00034193 * 1.25), "0.043%"); + assert_eq!(zoom_label(1e-7), "1.00e-5%"); + assert_ne!(zoom_label(f32::from_bits(1)), "0%"); + } +} + /// Constant-width labelled palette. Arming a tool never reallocates the canvas. pub fn palette<'a>( scope: Scope, diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs index b9d9649d..edf75aaa 100644 --- a/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/canvas_workspace/tests.rs @@ -371,6 +371,7 @@ fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_input fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() { use iced::widget::Space; let scope = Scope { + scenario_view: None, document: Id::new_v4(), owner: Id::new_v4(), revision: 2, @@ -430,6 +431,7 @@ pub(crate) fn scope(app: &App) -> Scope { Scope { document: context.document, owner: context.scenario, + scenario_view: Some((context.representation, context.visit)), revision: context.revision, epoch: app.camera_epoch, } @@ -438,6 +440,7 @@ pub(crate) fn scope(app: &App) -> Scope { Scope { document: context.document, owner: context.owner, + scenario_view: None, revision: context.revision, epoch: app.camera_epoch, } diff --git a/crates/oa-desktop/src/behavior/contextual_source_tests.rs b/crates/oa-desktop/src/behavior/contextual_source_tests.rs index 7e53ae0c..81b192cd 100644 --- a/crates/oa-desktop/src/behavior/contextual_source_tests.rs +++ b/crates/oa-desktop/src/behavior/contextual_source_tests.rs @@ -893,11 +893,14 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye .add_element(Kind::Scenario, Layer::Oa, "Second scenario", None) .unwrap(); let mut app = App::with_project(p.clone()); - app.page = Page::Behavior; - app.layer = Layer::Oa; - app.behavior.active_owner = Some(a); - app.selected = a; + // Capture the context created by actual navigation, including its visit. + // Merely assigning active_owner/selected does not mount a Scenario anymore. + let _ = app.update(Message::Select(a)); + let _ = app.update(Message::Page(Page::Behavior)); let context = app.scenario_context().unwrap(); + assert_eq!(context.scenario, a); + assert!(!context.visit.is_nil()); + let snapshot = app.document.snapshot(); let source = app.document.bundle().clone(); let begin = |context| scenario::CanvasEvent::BeginOccurrence { context, @@ -908,23 +911,40 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye }, lifeline: None, }; - app.behavior.active_owner = Some(b); - app.selected = b; + let _ = app.update(Message::Select(b)); + let second = app.scenario_context().unwrap(); + assert_eq!(second.scenario, b); + assert_ne!(second.visit, context.visit); let _ = app.update(Message::ScenarioCanvas(begin(context))); assert!(!app.behavior.has_pending()); assert_eq!(app.project(), &p); - app.behavior.active_owner = Some(a); - app.selected = a; - app.layer = Layer::Sa; - let _ = app.update(Message::ScenarioCanvas(begin(context))); + + let _ = app.update(Message::Select(a)); + let before_layer_change = app.scenario_context().unwrap(); + assert_eq!(before_layer_change.scenario, a); + assert_ne!(before_layer_change.visit, context.visit); + let _ = app.update(Message::Layer(Layer::Sa)); + assert_eq!(app.layer, Layer::Sa); + assert!(app.scenario_context().is_none()); + let _ = app.update(Message::ScenarioCanvas(begin(before_layer_change))); assert!(!app.behavior.has_pending()); - app.layer = Layer::Oa; + + let _ = app.update(Message::Layer(Layer::Oa)); + let _ = app.update(Message::Select(a)); let current = app.scenario_context().unwrap(); + assert_eq!(current.scenario, a); + assert_ne!(current.visit, before_layer_change.visit); + // Returning to the same owner/layer must not revive either retired packet. + for retired in [context, before_layer_change] { + let _ = app.update(Message::ScenarioCanvas(begin(retired))); + assert!(!app.behavior.has_pending()); + } + assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot())); let _ = app.update(Message::ScenarioCanvas(begin(current))); assert_eq!(app.behavior.pending_owner(), Some(a)); assert_eq!(app.project(), &p); assert_eq!(app.document.bundle(), &source); - assert!(!app.document.can_undo()); + assert!(!app.document.can_undo() && !app.document.can_redo()); } #[test] diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index a0583746..0df743ec 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -4,12 +4,28 @@ use super::topology::{occurrence, unique_batch}; use super::*; use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; +mod camera; pub(crate) mod focus; +pub(crate) mod spacing; +use super::scenario_layout::{ + Representation, + presentation::{self, ScenarioPresentation}, +}; +#[cfg(test)] +mod presentation_tests; mod usage; mod workspace; pub(crate) use workspace::NAME_INPUT; pub use workspace::{Shelf, view}; +// A picker may select the Saved diagram record itself. Another view of the +// same Scenario is not the property target of this displayed representation. +pub(crate) fn properties_target(p: &Project, context: Context, id: Id) -> bool { + super::selected_behavior(p, id) == Some(context.scenario) + || matches!(context.representation, Representation::Saved { diagram } if diagram == id) + && presentation::checked_owner(p, context.representation) == Some(context.scenario) +} + #[cfg(test)] #[path = "scenario_operation_tests.rs"] mod operation_tests; @@ -99,6 +115,8 @@ pub(crate) fn removal_target(p: &Project, owner: Id, id: Id) -> bool { pub struct Context { pub document: Id, pub scenario: Id, + pub representation: Representation, + pub visit: Id, pub revision: u64, pub epoch: u64, } @@ -125,6 +143,7 @@ impl std::fmt::Display for CanvasTool { } #[derive(Debug, Clone)] pub enum CanvasEvent { + Spacing(spacing::Event), Scrolled(Context, ScrollArea, iced::Vector), Restored(Context, Id, ScrollArea, iced::Vector), Viewport(Context, Size), @@ -132,9 +151,11 @@ pub enum CanvasEvent { Zoom(Context, Option), Camera(Context, u64, super::canvas_workspace::Event), Shelf(Context, Shelf), + Properties(Context, Id), OpenUse(Context, Id), Back(Context), Choose(Context, CanvasTool), + Representation(Context, Representation), Select(Context, Id), Remove(Context, Id), Begin { @@ -164,6 +185,7 @@ pub enum ScrollArea { impl CanvasEvent { pub fn context(&self) -> Context { match self { + Self::Spacing(event) => event.stamp().context, Self::Scrolled(context, _, _) | Self::Restored(context, ..) | Self::Viewport(context, _) @@ -171,9 +193,11 @@ impl CanvasEvent { | Self::Zoom(context, _) | Self::Camera(context, ..) | Self::Shelf(context, _) + | Self::Properties(context, _) | Self::OpenUse(context, _) | Self::Back(context) | Self::Choose(context, _) + | Self::Representation(context, _) | Self::Select(context, _) | Self::Remove(context, _) | Self::Begin { context, .. } @@ -231,6 +255,7 @@ pub enum ValueSection { const VALUE_PAGE_SIZE: usize = 24; #[derive(Default)] pub struct State { + spacing: Option, draft: Option, form_session: Id, form_mount: Id, @@ -246,6 +271,7 @@ const CALLER_LIMIT: usize = 32; pub(crate) struct Caller { document: Id, pub owner: Id, + pub representation: Representation, pub occurrence: Id, target: Id, tool: CanvasTool, @@ -255,6 +281,8 @@ pub(crate) struct Caller { struct DetailsRestore { document: Id, owner: Id, + representation: Representation, + visit: Id, token: Id, offset: iced::Vector, } @@ -262,6 +290,8 @@ struct DetailsRestore { pub(super) struct ScrollRestore { pub document: Id, pub owner: Id, + pub representation: Representation, + pub visit: Id, pub token: Id, pub offset: iced::Vector, } @@ -354,46 +384,158 @@ impl State { } pub(crate) fn enter(&mut self, document: Id, owner: Id) { - if self.presentation.identity != Some((document, owner)) { + self.enter_representation(document, owner, Representation::Derived { owner }); + } + /// App validates requested identity with checked_owner before entering. + pub(crate) fn enter_representation( + &mut self, + document: Id, + owner: Id, + representation: Representation, + ) -> bool { + if matches!(representation, Representation::Derived { owner: requested } if requested != owner) + { + return false; + } + let changed = self.presentation.identity != Some((document, owner)) + || self.presentation.representation != Some(representation); + if changed { self.presentation = workspace::Presentation { identity: Some((document, owner)), + representation: Some(representation), + fit: matches!(representation, Representation::Saved { .. }), ..Default::default() }; self.details_offset = iced::Vector::ZERO; - self.restore = None; + self.canvas_tool = Some((owner, CanvasTool::Select)); } + if changed || self.presentation.visit.is_nil() { + self.spacing = None; + self.presentation.visit = Id::new_v4(); + self.restore = None; + return true; + } + false + } + /// Suspend captured events while preserving this destination's camera state. + pub(crate) fn leave(&mut self) { + self.spacing = None; + self.presentation.visit = Id::nil(); + self.restore = None; + } + pub(crate) fn forget_view(&mut self) { + self.spacing = None; + self.presentation.identity = None; + self.presentation.representation = None; + self.presentation.visit = Id::nil(); + self.restore = None; + } + pub(crate) fn representation(&self) -> Option { + self.presentation.representation + } + pub(crate) fn owner(&self) -> Option { + self.presentation.identity.map(|(_, owner)| owner) + } + pub(crate) fn context( + &self, + document: Id, + owner: Id, + revision: u64, + epoch: u64, + ) -> Option { + if self.presentation.identity != Some((document, owner)) || self.presentation.visit.is_nil() + { + return None; + } + Some(Context { + document, + scenario: owner, + representation: self.representation()?, + visit: self.presentation.visit, + revision, + epoch, + }) + } + pub(crate) fn display( + &self, + p: &Project, + owner: Id, + selected: Id, + ) -> Option { + let (_, current_owner) = self.presentation.identity?; + let representation = self.representation()?; + (current_owner == owner).then(|| presentation::present(p, representation, selected)) + } + pub(crate) fn coordinate_input(&self, p: &Project, context: Context) -> bool { + self.context( + context.document, + context.scenario, + context.revision, + context.epoch, + ) == Some(context) + && self + .display(p, context.scenario, context.scenario) + .is_some_and(|shown| shown.capabilities().coordinate_input) } pub(crate) fn viewport(&mut self, size: Size) -> bool { self.presentation.viewport(size) } - pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool { + pub(crate) fn camera( + &mut self, + project: &Project, + owner: Id, + event: super::canvas_workspace::Event, + ) -> bool { + let Some(bounds) = self + .display(project, owner, owner) + .and_then(|shown| shown.bounds()) + else { + return false; + }; let p = &mut self.presentation; - super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event) + camera::change( + &mut p.pan, + &mut p.zoom, + &mut p.fit, + p.viewport, + bounds, + event, + ) } pub(crate) fn zoom(&mut self, scale: Option) -> bool { self.presentation.zoom(scale) } pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option) -> bool { - let size = Scene::sequence(p, owner, owner).size; - let old = self.presentation.scale(size); + let Some(bounds) = self + .display(p, owner, owner) + .and_then(|shown| shown.bounds()) + else { + return false; + }; if let Some(next) = zoom { + let current = self.presentation.camera(bounds); let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.)); - self.camera(super::canvas_workspace::Event::Zoom { - factor: next / old, - at: Point::new(viewport.width / 2., viewport.height / 2.), - base: old, - }) + self.camera( + p, + owner, + super::canvas_workspace::Event::Zoom { + factor: next / current.scale, + at: Point::new(viewport.width / 2., viewport.height / 2.), + base: current.scale, + }, + ) } else { - self.zoom(None); - let fitted = self.presentation.scale(size); - self.zoom(Some(fitted)); - self.presentation.pan = iced::Vector::ZERO; - true + self.zoom(None) } } pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) { self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf); } + pub(crate) fn show_properties(&mut self) { + // Panels remain available. Dismiss the competing outline without + // replacing this view or its camera. + self.presentation.shelf = None; + } pub(crate) fn open_use( &mut self, p: &Project, @@ -410,6 +552,7 @@ impl State { self.return_to.push(Caller { document: context.document, owner: context.scenario, + representation: context.representation, occurrence, target, tool: self.canvas_tool(context.scenario), @@ -424,6 +567,7 @@ impl State { fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool { frame.document == context.document && frame.target == context.scenario + && presentation::checked_owner(p, frame.representation) == Some(frame.owner) && referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target) } pub(super) fn can_back(&self, p: &Project, context: Context) -> bool { @@ -440,16 +584,23 @@ impl State { None } pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) { - if context.document != frame.document || context.scenario != frame.owner { + if context.document != frame.document + || context.scenario != frame.owner + || context.representation != frame.representation + { return; } self.canvas_tool = Some((frame.owner, frame.tool)); self.details_offset = frame.details_offset; self.presentation = frame.presentation; self.presentation.identity = Some((context.document, context.scenario)); + self.presentation.representation = Some(frame.representation); + self.presentation.visit = context.visit; self.restore = Some(DetailsRestore { document: context.document, owner: frame.owner, + representation: frame.representation, + visit: context.visit, token: Id::new_v4(), offset: frame.details_offset, }); @@ -459,13 +610,17 @@ impl State { context: Context, ScrollArea::Page: ScrollArea, ) -> Option { - let restore = self - .restore - .as_ref() - .filter(|r| r.document == context.document && r.owner == context.scenario)?; + let restore = self.restore.as_ref().filter(|r| { + r.document == context.document + && r.owner == context.scenario + && r.representation == context.representation + && r.visit == context.visit + })?; Some(ScrollRestore { document: restore.document, owner: restore.owner, + representation: restore.representation, + visit: restore.visit, token: restore.token, offset: restore.offset, }) @@ -478,7 +633,11 @@ impl State { offset: iced::Vector, ) -> bool { if !self.restore.as_ref().is_some_and(|r| { - r.document == context.document && r.owner == context.scenario && r.token == token + r.document == context.document + && r.owner == context.scenario + && r.representation == context.representation + && r.visit == context.visit + && r.token == token }) { return false; } @@ -1951,6 +2110,8 @@ pub(crate) mod direct_tests { Context { document: Id::new_v4(), scenario: owner, + representation: Representation::Derived { owner }, + visit: Id::new_v4(), revision: 0, epoch: 0, } diff --git a/crates/oa-desktop/src/behavior/scenario/camera.rs b/crates/oa-desktop/src/behavior/scenario/camera.rs new file mode 100644 index 00000000..bc6cead6 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/camera.rs @@ -0,0 +1,284 @@ +//! Scenario camera policy. Bounds are from the exact displayed presentation, +//! including its origin and automatic fallback; no model geometry is authored. +use crate::behavior::canvas_workspace::{self, Event}; +use iced::{Rectangle, Size, Vector}; + +pub(super) const MIN_SCALE: f32 = f32::from_bits(1); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Camera { + pub(super) scale: f32, + pub(super) pan: Vector, +} + +/// Fit never enlarges above 100%. Use f64 intermediates so a valid f32 origin +/// and size cannot overflow while finding the center. Unrepresentable cameras +/// are refused rather than silently clamped to an unrelated minimum scale. +pub(super) fn fit(bounds: Rectangle, viewport: Size) -> Option { + if [ + bounds.x, + bounds.y, + bounds.width, + bounds.height, + viewport.width, + viewport.height, + ] + .iter() + .any(|n| !n.is_finite()) + || bounds.width < 0. + || bounds.height < 0. + || viewport.width <= 0. + || viewport.height <= 0. + { + return None; + } + let width = f64::from(viewport.width); + let height = f64::from(viewport.height); + let scale = ((width - 2. * (width / 4.).min(8.)) / f64::from(bounds.width).max(1.)) + .min((height - 2. * (height / 4.).min(8.)) / f64::from(bounds.height).max(1.)) + .min(1.) as f32; + if !scale.is_finite() || scale <= 0. { + return None; + } + let x = width / 2. - (f64::from(bounds.x) + f64::from(bounds.width) / 2.) * f64::from(scale); + let y = height / 2. - (f64::from(bounds.y) + f64::from(bounds.height) / 2.) * f64::from(scale); + let pan = Vector::new(x as f32, y as f32); + (pan.x.is_finite() && pan.y.is_finite()).then_some(Camera { scale, pan }) +} + +pub(super) fn effective_camera( + pan: Vector, + zoom: Option, + fitting: bool, + viewport: Option, + bounds: Rectangle, +) -> Camera { + if fitting && let Some(camera) = viewport.and_then(|size| fit(bounds, size)) { + return camera; + } + Camera { + scale: zoom.unwrap_or(1.), + pan, + } +} + +/// None preserves Fit as a mode, including before the first viewport arrives. +pub(super) fn set_zoom( + pan: &mut Vector, + zoom: &mut Option, + fitting: &mut bool, + scale: Option, +) -> bool { + if scale.is_some_and(|value| !value.is_finite() || value <= 0.) { + return false; + } + let next = scale.map(|value| value.min(3.)); + let next_pan = if scale.is_none() { Vector::ZERO } else { *pan }; + let changed = *zoom != next || *fitting != scale.is_none() || *pan != next_pan; + *zoom = next; + *fitting = scale.is_none(); + *pan = next_pan; + changed +} + +/// Materialize the visible fitted camera only when a camera gesture succeeds. +/// A zero-motion or rejected event preserves the original mode and fields. +pub(super) fn change( + pan: &mut Vector, + zoom: &mut Option, + fitting: &mut bool, + viewport: Option, + bounds: Rectangle, + event: Event, +) -> bool { + let camera = effective_camera(*pan, *zoom, *fitting, viewport, bounds); + let (mut next_pan, mut next_zoom, mut next_fit) = (camera.pan, Some(camera.scale), false); + let event = match event { + Event::Zoom { base, .. } if !base.is_finite() || base <= 0. => return false, + Event::Zoom { factor, at, .. } => Event::Zoom { + factor, + at, + base: camera.scale, + }, + event => event, + }; + if !canvas_workspace::change_with_minimum( + &mut next_pan, + &mut next_zoom, + &mut next_fit, + event, + MIN_SCALE, + ) { + return false; + } + *pan = next_pan; + *zoom = next_zoom; + *fitting = next_fit; + true +} + +#[cfg(test)] +mod tests { + use super::*; + use iced::Point; + + fn bounds() -> Rectangle { + Rectangle { + x: -1_000_160., + y: -200., + width: 2_000_400., + height: 1_000., + } + } + fn near(actual: f32, expected: f32, tolerance: f32) { + assert!( + (actual - expected).abs() <= tolerance, + "{actual} != {expected}" + ); + } + fn projected(camera: Camera, point: Point) -> Point { + Point::new( + point.x * camera.scale + camera.pan.x, + point.y * camera.scale + camera.pan.y, + ) + } + + #[test] + fn negative_origin_fit_contains_independently_known_extremes() { + let camera = fit(bounds(), Size::new(700., 500.)).unwrap(); + near(camera.scale, 0.0003419316, 1e-10); + near(camera.pan.x, 349.98633, 0.0001); + near( + projected(camera, Point::new(-1_000_160., -200.)).x, + 8., + 0.0001, + ); + near( + projected(camera, Point::new(1_000_240., 800.)).x, + 692., + 0.0001, + ); + near(projected(camera, Point::new(40., 300.)).y, 250., 0.0001); + } + + #[test] + fn fit_remains_active_across_first_and_changed_viewport() { + let (mut pan, mut zoom, mut fitting) = (Vector::new(50., 80.), Some(0.8), false); + assert!(set_zoom(&mut pan, &mut zoom, &mut fitting, None)); + let retained = effective_camera(pan, zoom, fitting, Some(Size::new(1200., 500.)), bounds()); + let actual = effective_camera(pan, zoom, fitting, Some(Size::new(500., 500.)), bounds()); + assert!(actual.scale < retained.scale); + near( + projected(actual, Point::new(-1_000_160., -200.)).x, + 8., + 0.0001, + ); + near( + projected(actual, Point::new(1_000_240., 800.)).x, + 492., + 0.0001, + ); + assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true)); + } + + #[test] + fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() { + let size = Size::new(700., 500.); + let viewport = Some(size); + let start = fit(bounds(), size).unwrap(); + let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true); + let at = Point::new(210., 180.); + let world = Point::new( + (at.x - start.pan.x) / start.scale, + (at.y - start.pan.y) / start.scale, + ); + for factor in [1.25, 0.8] { + let prior_scale = zoom.unwrap_or(start.scale); + assert!(change( + &mut pan, + &mut zoom, + &mut fitting, + viewport, + bounds(), + Event::Zoom { + factor, + at, + base: start.scale, + } + )); + let camera = effective_camera(pan, zoom, fitting, viewport, bounds()); + near(camera.scale, prior_scale * factor, 1e-10); + assert!(camera.scale < 0.001); + near(projected(camera, world).x, at.x, 0.0001); + near(projected(camera, world).y, at.y, 0.0001); + } + near(zoom.unwrap(), start.scale, 1e-10); + near(pan.x, start.pan.x, 0.0001); + assert!(!fitting); + } + + #[test] + fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() { + let size = Size::new(700., 500.); + let viewport = Some(size); + let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true); + for event in [ + Event::Pan(Vector::ZERO), + Event::Zoom { + factor: 1., + at: Point::ORIGIN, + base: 1., + }, + ] { + assert!(!change( + &mut pan, + &mut zoom, + &mut fitting, + viewport, + bounds(), + event + )); + assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true)); + } + let start = fit(bounds(), size).unwrap(); + assert!(change( + &mut pan, + &mut zoom, + &mut fitting, + viewport, + bounds(), + Event::Pan(Vector::new(20., -30.)) + )); + assert_eq!(pan, start.pan + Vector::new(20., -30.)); + assert_eq!(zoom, Some(start.scale)); + assert!(!fitting); + } + + #[test] + fn invalid_inputs_are_passive_and_large_finite_bounds_do_not_overflow_center() { + assert!(fit(bounds(), Size::new(f32::NAN, 500.)).is_none()); + let extreme = Rectangle { + x: f32::MAX, + y: 0., + width: f32::MAX, + height: 100., + }; + let camera = fit(extreme, Size::new(700., 500.)).unwrap(); + assert!(camera.scale > 0. && camera.scale < 1e-30 && camera.pan.x.is_finite()); + let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true); + assert!(!set_zoom(&mut pan, &mut zoom, &mut fitting, Some(f32::NAN))); + assert!(!change( + &mut pan, + &mut zoom, + &mut fitting, + Some(Size::new(700., 500.)), + bounds(), + Event::Zoom { + factor: 1.25, + at: Point::ORIGIN, + base: f32::NAN + } + )); + assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true)); + } +} diff --git a/crates/oa-desktop/src/behavior/scenario/focus.rs b/crates/oa-desktop/src/behavior/scenario/focus.rs index 9f6b0b73..4c10f429 100644 --- a/crates/oa-desktop/src/behavior/scenario/focus.rs +++ b/crates/oa-desktop/src/behavior/scenario/focus.rs @@ -11,6 +11,8 @@ use std::collections::BTreeMap; pub(crate) struct Stamp { document: ModelId, owner: ModelId, + representation: super::Representation, + visit: ModelId, revision: u64, session: ModelId, mount: ModelId, @@ -20,6 +22,8 @@ impl Stamp { Self { document: context.document, owner: context.scenario, + representation: context.representation, + visit: context.visit, revision: context.revision, session, mount, @@ -28,10 +32,12 @@ impl Stamp { } pub(crate) fn name_id(stamp: Stamp) -> Id { Id::from(format!( - "{}:{}:{}:{}:{}:{}", + "{}:{}:{}:{:?}:{}:{}:{}:{}", super::NAME_INPUT, stamp.document, stamp.owner, + stamp.representation, + stamp.visit, stamp.revision, stamp.session, stamp.mount diff --git a/crates/oa-desktop/src/behavior/scenario/presentation_tests.rs b/crates/oa-desktop/src/behavior/scenario/presentation_tests.rs new file mode 100644 index 00000000..7c3e92e4 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/presentation_tests.rs @@ -0,0 +1,348 @@ +//! Controller identity and actual presentation input, independent of App routing. +use super::*; +use oa_core::{Diagram, DiagramLayout, ProjectionKind, ScenarioLanePosition, ScenarioLayout}; + +fn saved(p: &mut Project, owner: Id, lanes: &[(Id, f32)]) -> Representation { + let id = Id::new_v4(); + p.diagrams.insert( + id, + Diagram { + id, + alias: format!("saved_{}", p.diagrams.len()), + name: "Saved scenario".into(), + description: String::new(), + layer: p.elements[&owner].layer(), + projection: ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + layout: Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: lanes + .iter() + .map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x }) + .collect(), + messages: vec![], + })), + }, + ); + let errors: Vec<_> = oa_core::validation::validate(p) + .into_iter() + .filter(|issue| issue.severity == oa_core::validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); + Representation::Saved { diagram: id } +} + +#[test] +fn saved_visits_distinguish_same_owner_roundtrips_and_suspended_return_without_editing() { + let mut f = direct_tests::fixture(); + let a = saved(&mut f.project, f.owner, &[(f.sender, -500.)]); + let b = saved(&mut f.project, f.owner, &[(f.sender, 900.)]); + let original = f.project.clone(); + let document = Id::new_v4(); + let mut state = State::default(); + assert!(state.enter_representation(document, f.owner, a)); + let first = state.context(document, f.owner, 7, 9).unwrap(); + assert!(!state.enter_representation(document, f.owner, a)); + assert_eq!(state.context(document, f.owner, 7, 9), Some(first)); + state.presentation.pan = iced::Vector::new(23., -17.); + state.leave(); + assert_eq!(state.representation(), Some(a)); + assert_eq!(state.owner(), Some(f.owner)); + assert!(state.context(document, f.owner, 7, 9).is_none()); + assert!(state.enter_representation(document, f.owner, a)); + let returned = state.context(document, f.owner, 7, 9).unwrap(); + assert_ne!(returned.visit, first.visit); + assert_eq!(state.presentation.pan, iced::Vector::new(23., -17.)); + assert!(state.enter_representation(document, f.owner, b)); + let middle = state.context(document, f.owner, 7, 9).unwrap(); + assert!(state.enter_representation(document, f.owner, a)); + let last = state.context(document, f.owner, 7, 9).unwrap(); + assert_ne!(last.visit, returned.visit); + assert_ne!(last.visit, middle.visit); + assert!(!state.coordinate_input(&f.project, first)); + assert!(state.coordinate_input(&f.project, last)); + let session = Id::new_v4(); + let mount = Id::new_v4(); + assert_ne!( + focus::Stamp::new(first, session, mount), + focus::Stamp::new(last, session, mount) + ); + assert_eq!(f.project, original); + state.forget_view(); + assert_eq!(state.representation(), None); + assert_eq!(state.owner(), None); + assert!(state.context(document, f.owner, 7, 9).is_none()); +} + +#[test] +fn saved_caller_restores_exact_representation_with_fresh_visit_and_rejects_deleted_view() { + let (mut project, [a, b, _], [reference, _, _]) = direct_tests::navigation_fixture(); + let view = saved(&mut project, a, &[]); + let original = project.clone(); + let document = Id::new_v4(); + let mut state = State::default(); + state.enter_representation(document, a, view); + let initial = state.context(document, a, 0, 0).unwrap(); + state.presentation.pan = iced::Vector::new(11., 29.); + state.details_offset = iced::Vector::new(0., 71.); + assert_eq!(state.open_use(&project, initial, reference).unwrap(), b); + state.enter(document, b); + let callee = state.context(document, b, 0, 0).unwrap(); + let frame = state.back(&project, callee).unwrap(); + assert_eq!(frame.representation, view); + state.enter_representation(document, a, frame.representation); + let returned = state.context(document, a, 0, 0).unwrap(); + state.restore_caller(returned, frame); + assert_ne!(returned.visit, initial.visit); + assert_eq!(state.representation(), Some(view)); + assert_eq!(state.presentation.pan, iced::Vector::new(11., 29.)); + assert!(state.scroll_restore(initial, ScrollArea::Page).is_none()); + assert_eq!( + state + .scroll_restore(returned, ScrollArea::Page) + .unwrap() + .offset, + iced::Vector::new(0., 71.) + ); + assert_eq!(project, original); + state.open_use(&project, returned, reference).unwrap(); + state.enter(document, b); + let Representation::Saved { diagram } = view else { + unreachable!() + }; + project.diagrams.remove(&diagram); + let callee = state.context(document, b, 0, 0).unwrap(); + assert!(state.back(&project, callee).is_none()); +} + +#[test] +fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestures() { + let mut f = direct_tests::fixture(); + // Equal authored lane centers are structurally valid, but cannot render a distinct-endpoint call. + let rep = saved( + &mut f.project, + f.owner, + &[(f.sender, 900.), (f.receiver, 900.)], + ); + let original = f.project.clone(); + let mut state = State::default(); + state.enter_representation(Id::new_v4(), f.owner, rep); + let document = state.presentation.identity.unwrap().0; + let context = state.context(document, f.owner, 0, 0).unwrap(); + let shown = state.display(&f.project, f.owner, f.call).unwrap(); + assert!(matches!( + shown.status(), + presentation::PresentationStatus::Fallback { .. } + )); + assert!(!state.coordinate_input(&f.project, context)); + let automatic = presentation::present( + &f.project, + Representation::Derived { owner: f.owner }, + f.call, + ); + let automatic = automatic.scene().unwrap(); + assert_eq!(shown.scene().unwrap().render_lanes, automatic.render_lanes); + assert_eq!(shown.scene().unwrap().activations, automatic.activations); + assert!(!automatic.activations.is_empty()); + let mut scene = shown.into_scene().unwrap(); + scene.coordinate_input = false; + let line = scene.lines.iter().find(|line| line.id == f.call).unwrap(); + let point = Point::new( + (line.points[0].x + line.points.last().unwrap().x) / 2., + line.points[0].y, + ); + assert_eq!(scene.hit(point), Some(f.call)); + let bounds = Rectangle::with_size(Size::new(1600., 1000.)); + for tool in [ + CanvasTool::Select, + CanvasTool::Message(MessageStyle::CallReply), + CanvasTool::Add(Tool::Reuse), + ] { + scene.interaction = Some((context, tool, true)); + let mut input = SequenceInput::default(); + let mut messages = vec![]; + for event in [ + mouse::Event::CursorMoved { position: point }, + mouse::Event::ButtonPressed(mouse::Button::Left), + mouse::Event::CursorMoved { + position: Point::new(point.x, point.y + 100.), + }, + mouse::Event::ButtonReleased(mouse::Button::Left), + ] { + if let Some(message) = canvas::Program::update( + &scene, + &mut input, + &canvas::Event::Mouse(event), + bounds, + mouse::Cursor::Available(point), + ) + .and_then(|action| action.into_inner().0) + { + messages.push(message); + } + } + assert_eq!(messages.len(), 1); + assert!( + matches!(messages[0], Message::ScenarioCanvas(CanvasEvent::Select(c, id)) if c == context && id == f.call) + ); + assert!(input.press.is_none() && input.source.is_none()); + } + // Capture from an actual Ready presentation, then revoke coordinate input + // with the complete fallback frame of the same Saved representation. + let Representation::Saved { diagram } = rep else { + unreachable!() + }; + let mut ready_project = f.project.clone(); + ready_project.diagrams.get_mut(&diagram).unwrap().layout = + Some(DiagramLayout::Scenario(ScenarioLayout::default())); + let ready_original = ready_project.clone(); + let ready = state.display(&ready_project, f.owner, f.call).unwrap(); + assert!(matches!( + ready.status(), + presentation::PresentationStatus::Ready + )); + let mut ready_scene = ready.into_scene().unwrap(); + let line = ready_scene + .lines + .iter() + .find(|line| line.id == f.call) + .unwrap(); + let ready_point = Point::new( + (line.points[0].x + line.points.last().unwrap().x) / 2., + line.points[0].y, + ); + let hit = ready_scene.hit_item(ready_point).unwrap(); + assert_eq!(hit.id, f.call); + assert_eq!(hit.role, SceneHitRole::MessageBody); + let stamp = spacing::Stamp { + context, + camera_epoch: 17, + }; + ready_scene.coordinate_input = true; + ready_scene.interaction = Some((context, CanvasTool::Select, true)); + ready_scene.spacing_stamp = Some(stamp); + let mut input = SequenceInput::default(); + let begin = canvas::Program::update( + &ready_scene, + &mut input, + &canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + bounds, + mouse::Cursor::Available(ready_point), + ) + .and_then(|action| action.into_inner().0) + .unwrap(); + let Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { + stamp: captured_stamp, + token, + target: spacing::Target::Message(target), + .. + })) = begin + else { + panic!("Ready Saved message must begin a stamped spacing gesture"); + }; + assert_eq!(captured_stamp, stamp); + assert_eq!(target, f.call); + assert!(matches!(input.press, Some(SequencePress::Spacing { + stamp: held_stamp, token: held_token + }) if held_stamp == stamp && held_token == token)); + scene.interaction = Some((context, CanvasTool::Select, true)); + assert!(!scene.coordinate_input && scene.spacing_stamp.is_none()); + let release = canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)); + let result = canvas::Program::update( + &scene, + &mut input, + &release, + bounds, + mouse::Cursor::Available(ready_point), + ) + .and_then(|action| action.into_inner().0); + assert!( + matches!(result, Some(Message::ScenarioCanvas(CanvasEvent::Spacing( + spacing::Event::Cancel { stamp: cancelled_stamp, token: cancelled_token } + ))) if cancelled_stamp == stamp && cancelled_token == token) + ); + assert!(input.press.is_none() && input.source.is_none()); + assert!( + canvas::Program::update( + &scene, + &mut input, + &release, + bounds, + mouse::Cursor::Available(ready_point), + ) + .and_then(|action| action.into_inner().0) + .is_none() + ); + assert_eq!(ready_project, ready_original); + assert_eq!(f.project, original); +} + +#[test] +fn saved_camera_fits_actual_negative_origin_and_tiny_scale_without_authored_mutation() { + let mut f = direct_tests::fixture(); + let rep = saved( + &mut f.project, + f.owner, + &[(f.sender, -1_000_000.), (f.receiver, 1_000_000.)], + ); + let original = f.project.clone(); + let mut state = State::default(); + state.enter_representation(Id::new_v4(), f.owner, rep); + state.viewport(Size::new(700., 500.)); + assert!( + state.presentation.fit, + "new saved views await their real viewport in Fit mode" + ); + assert!( + !state.camera_zoom(&f.project, f.owner, None), + "already-active Fit is idempotent" + ); + let shown = state.display(&f.project, f.owner, f.owner).unwrap(); + assert!(matches!( + shown.status(), + presentation::PresentationStatus::Ready + )); + let bounds = shown.bounds().unwrap(); + assert!(bounds.x < -1_000_000. && bounds.x + bounds.width > 1_000_000.); + let fitted = state.presentation.camera(bounds); + assert!(fitted.scale > 0. && fitted.scale < 0.001); + let left = bounds.x * fitted.scale + fitted.pan.x; + let right = (bounds.x + bounds.width) * fitted.scale + fitted.pan.x; + assert!((left - 8.).abs() < 0.001 && (right - 692.).abs() < 0.001); + assert!(state.camera( + &f.project, + f.owner, + super::super::canvas_workspace::Event::Pan(iced::Vector::new(13., -7.)) + )); + let moved = state.presentation.camera(bounds); + assert_eq!(moved.scale, fitted.scale); + assert_eq!(moved.pan, fitted.pan + iced::Vector::new(13., -7.)); + assert!(!state.presentation.fit); + assert_eq!(f.project, original); +} + +#[test] +fn deleted_saved_identity_remains_unavailable_and_never_falls_into_derived_geometry() { + let mut f = direct_tests::fixture(); + let rep = saved(&mut f.project, f.owner, &[]); + let mut state = State::default(); + let document = Id::new_v4(); + state.enter_representation(document, f.owner, rep); + let Representation::Saved { diagram } = rep else { + unreachable!() + }; + f.project.diagrams.remove(&diagram); + let original = f.project.clone(); + let shown = state.display(&f.project, f.owner, f.owner).unwrap(); + assert_eq!(shown.requested(), rep); + assert!(matches!( + shown.status(), + presentation::PresentationStatus::Unavailable { .. } + )); + assert!(shown.scene().is_none() && shown.bounds().is_none()); + assert_eq!(state.owner(), Some(f.owner)); + assert!(state.context(document, f.owner, 0, 0).is_some()); + assert!(!state.camera_zoom(&f.project, f.owner, None)); + assert_eq!(f.project, original); +} diff --git a/crates/oa-desktop/src/behavior/scenario/spacing.rs b/crates/oa-desktop/src/behavior/scenario/spacing.rs new file mode 100644 index 00000000..bc120208 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/spacing.rs @@ -0,0 +1,160 @@ +//! Pure Saved-only spacing preparation. Delta is measured from the original +//! pointer in the captured camera's world coordinates, never from the last +//! preview. The controller must own document/revision/visit, camera and hit-role +//! admission; this module neither dispatches commands nor changes selection. +use crate::behavior::scenario_layout::{ + GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve, +}; +use oa_core::{ + Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition, + ScenarioMessagePosition, SharedBody, execute_batch, +}; + +pub(crate) mod controller; +#[cfg(test)] +mod tests; +pub(crate) use controller::{Event, Stamp}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Target { + Lane(Id), + Message(Id), +} +impl Target { + pub(crate) fn id(self) -> Id { + match self { + Self::Lane(id) | Self::Message(id) => id, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Refusal { + InvalidSavedView(Id), + TargetNotRendered(Target), + InvalidDelta, + InvalidGesture, + CameraChanged, + TargetNotHit(Target), + Command(String), + Geometry(Vec), +} + +pub(super) struct Drag { + original: Project, + diagram: Id, + target: Target, + origin: f32, + original_geometry: ResolvedScenarioPlan, +} + +pub(super) struct Preview { + pub(super) command: Command, + #[cfg(test)] + pub(super) project: Project, + pub(super) geometry: ResolvedScenarioPlan, +} + +/// Capture an accepted, actually drawn target. A Saved view displaying automatic +/// fallback is not an editable original, even if the target appears in fallback. +pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result { + let representation = Representation::Saved { diagram }; + let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?; + let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?; + let element = project.elements.get(&target.id()); + let origin = match target { + Target::Lane(id) + if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) => + { + // Unassigned lifelines still have drawn headers. `scene.lanes` + // excludes them only from semantic message-endpoint hit testing. + geometry.scene.render_lanes.get(&id).copied() + } + Target::Message(id) + if element.is_some_and(|e| { + matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { .. }, + .. + }) + ) + }) => + { + // A lifecycle row can denote receive y; the public position means + // send y. Read the actual outgoing endpoint, including self calls. + geometry + .scene + .lines + .iter() + .find(|line| line.id == id) + .and_then(|line| line.points.first()) + .map(|point| point.y) + } + _ => None, + } + .ok_or(Refusal::TargetNotRendered(target))?; + if !origin.is_finite() { + return Err(Refusal::TargetNotRendered(target)); + } + Ok(Drag { + original: project.clone(), + diagram, + target, + origin, + original_geometry: geometry, + }) +} + +impl Drag { + /// None means there is no command to commit. In particular +0/-0, return to + /// start, and sub-precision motion never materialize a missing override or + /// normalize an existing explicit value. Document/redo are never touched. + pub(super) fn prepare(&self, original_delta: f32) -> Result, Refusal> { + if !original_delta.is_finite() { + return Err(Refusal::InvalidDelta); + } + if original_delta == 0. { + return Ok(None); + } + let position = (f64::from(self.origin) + f64::from(original_delta)) as f32; + if position == self.origin { + return Ok(None); + } + let command = match self.target { + Target::Lane(lifeline) => Command::SetScenarioLanePosition { + diagram: self.diagram, + position: ScenarioLanePosition { + lifeline, + x: position, + }, + }, + Target::Message(occurrence) => Command::SetScenarioMessagePosition { + diagram: self.diagram, + position: ScenarioMessagePosition { + address: OccurrenceEndpoint { + references: vec![], + occurrence, + }, + send_y: position, + }, + }, + }; + let project = execute_batch(&self.original, std::slice::from_ref(&command)) + .map_err(|error| Refusal::Command(error.to_string()))?; + let geometry = resolve( + &project, + Representation::Saved { + diagram: self.diagram, + }, + self.target.id(), + ) + .map_err(Refusal::Geometry)?; + Ok(Some(Preview { + command, + #[cfg(test)] + project, + geometry, + })) + } +} diff --git a/crates/oa-desktop/src/behavior/scenario/spacing/controller.rs b/crates/oa-desktop/src/behavior/scenario/spacing/controller.rs new file mode 100644 index 00000000..44ef0b59 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/spacing/controller.rs @@ -0,0 +1,300 @@ +//! One press owns one immutable accepted Project and one frozen display camera. +use super::*; +use crate::behavior::{ + Scene, SceneHitRole, + scenario::{CanvasTool, Context, State}, + scenario_layout::Diagnostic, +}; +use iced::{Point, Rectangle, Vector}; + +#[cfg(test)] +pub(crate) mod tests; +#[cfg(test)] +mod timing_tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stamp { + pub(crate) context: Context, + pub(crate) camera_epoch: u64, +} + +#[derive(Clone, Copy, Debug)] +pub enum Event { + Begin { + stamp: Stamp, + token: Id, + target: Target, + press_screen: Point, + canvas_bounds: Rectangle, + scale: f32, + pan: Vector, + }, + Move { + stamp: Stamp, + token: Id, + screen: Point, + }, + Finish { + stamp: Stamp, + token: Id, + screen: Point, + }, + Cancel { + stamp: Stamp, + token: Id, + }, +} +impl Event { + pub(crate) fn stamp(self) -> Stamp { + match self { + Self::Begin { stamp, .. } + | Self::Move { stamp, .. } + | Self::Finish { stamp, .. } + | Self::Cancel { stamp, .. } => stamp, + } + } + pub(crate) fn token(self) -> Id { + match self { + Self::Begin { token, .. } + | Self::Move { token, .. } + | Self::Finish { token, .. } + | Self::Cancel { token, .. } => token, + } + } +} + +pub(crate) struct Session { + stamp: Stamp, + token: Id, + press_screen: Point, + scale: f32, + pan: Vector, + drag: Drag, + preview: Option, + refusal: Option, + ever_crossed: bool, +} + +pub(in crate::behavior) struct Paint<'a> { + pub(in crate::behavior) scene: &'a Scene, + #[cfg(test)] + pub(in crate::behavior) bounds: Rectangle, + pub(in crate::behavior) refusal: Option<&'a Refusal>, + pub(in crate::behavior) original_diagnostics: &'a [Diagnostic], + pub(in crate::behavior) original_latent: &'a [Id], + pub(in crate::behavior) diagnostics: &'a [Diagnostic], +} + +pub(crate) enum Finish { + Click(Id), + Noop, + Commit { + command: Box, + selected: Id, + scale: f32, + pan: Vector, + }, +} + +impl Session { + fn configure(&self, scene: &mut Scene) { + scene.selected = self.drag.target.id(); + scene.scale = self.scale; + scene.camera_pan = self.pan; + scene.interaction = Some((self.stamp.context, CanvasTool::Select, true)); + scene.spacing_stamp = Some(self.stamp); + scene.coordinate_input = true; + scene.pending_placement = None; + } + fn update(&mut self, screen: Point) { + self.preview = None; + self.refusal = None; + if !screen.x.is_finite() || !screen.y.is_finite() { + self.refusal = Some(Refusal::InvalidDelta); + return; + } + let distance = match self.drag.target { + Target::Lane(_) => screen.x - self.press_screen.x, + Target::Message(_) => screen.y - self.press_screen.y, + }; + self.ever_crossed |= distance.abs() >= 3.; + if !self.ever_crossed { + return; + } + match self.drag.prepare(distance / self.scale) { + Ok(Some(mut preview)) => { + self.configure(&mut preview.geometry.scene); + self.preview = Some(preview); + } + Ok(None) => {} + Err(error) => self.refusal = Some(error), + } + } +} + +impl State { + pub(crate) fn spacing_token(&self) -> Option { + self.spacing.as_ref().map(|s| s.token) + } + pub(crate) fn spacing_stamp(&self) -> Option { + self.spacing.as_ref().map(|s| s.stamp) + } + pub(crate) fn cancel_spacing(&mut self) -> bool { + self.spacing.take().is_some() + } + + pub(in crate::behavior) fn spacing_paint( + &self, + context: Context, + camera_epoch: u64, + ) -> Option> { + let session = self.spacing.as_ref()?; + if session.stamp + != (Stamp { + context, + camera_epoch, + }) + { + return None; + } + let original = &session.drag.original_geometry; + let shown = session.preview.as_ref().map_or(original, |p| &p.geometry); + Some(Paint { + scene: &shown.scene, + #[cfg(test)] + bounds: shown.bounds, + refusal: session.refusal.as_ref(), + original_diagnostics: &original.plan.diagnostics, + original_latent: &original.latent_messages, + diagnostics: &shown.plan.diagnostics, + }) + } + + pub(crate) fn begin_spacing(&mut self, project: &Project, event: Event) -> Result<(), Refusal> { + let Event::Begin { + stamp, + token, + target, + press_screen, + canvas_bounds, + scale, + pan, + } = event + else { + return Err(Refusal::InvalidGesture); + }; + let context = stamp.context; + if self.spacing.is_some() + || token.is_nil() + || self.context( + context.document, + context.scenario, + context.revision, + context.epoch, + ) != Some(context) + || self.canvas_tool(context.scenario) != CanvasTool::Select + || ![ + press_screen.x, + press_screen.y, + canvas_bounds.x, + canvas_bounds.y, + canvas_bounds.width, + canvas_bounds.height, + scale, + pan.x, + pan.y, + ] + .into_iter() + .all(f32::is_finite) + || scale <= 0. + || canvas_bounds.width <= 0. + || canvas_bounds.height <= 0. + || !canvas_bounds.contains(press_screen) + { + return Err(Refusal::InvalidGesture); + } + let Representation::Saved { diagram } = context.representation else { + return Err(Refusal::InvalidGesture); + }; + let mut drag = capture(project, diagram, target)?; + let camera = self.presentation.camera(drag.original_geometry.bounds); + if self.presentation.viewport != Some(canvas_bounds.size()) + || camera.scale != scale + || camera.pan != pan + { + return Err(Refusal::CameraChanged); + } + let world = Point::new( + (press_screen.x - canvas_bounds.x - pan.x) / scale, + (press_screen.y - canvas_bounds.y - pan.y) / scale, + ); + // Hit tolerance is measured in displayed pixels, just like the widget. + drag.original_geometry.scene.scale = scale; + drag.original_geometry.scene.camera_pan = pan; + let hit = drag.original_geometry.scene.hit_item(world); + if !hit.is_some_and(|hit| { + hit.id == target.id() + && match target { + Target::Lane(_) => hit.role == SceneHitRole::LifelineHeader, + Target::Message(_) => matches!( + hit.role, + SceneHitRole::MessageBody | SceneHitRole::MessageLabel + ), + } + }) { + return Err(Refusal::TargetNotHit(target)); + } + // Configure baseline without changing the persistent camera or selection. + let scene = &mut drag.original_geometry.scene; + scene.selected = target.id(); + scene.scale = scale; + scene.camera_pan = pan; + scene.interaction = Some((context, CanvasTool::Select, true)); + scene.spacing_stamp = Some(stamp); + scene.coordinate_input = true; + scene.pending_placement = None; + self.spacing = Some(Session { + stamp, + token, + press_screen, + scale, + pan, + drag, + preview: None, + refusal: None, + ever_crossed: false, + }); + Ok(()) + } + pub(crate) fn move_spacing(&mut self, screen: Point) { + if let Some(session) = &mut self.spacing { + session.update(screen); + } + } + pub(crate) fn finish_spacing(&mut self, screen: Point) -> Result { + let Some(mut session) = self.spacing.take() else { + return Err(Refusal::InvalidGesture); + }; + session.update(screen); + if let Some(error) = session.refusal { + return Err(error); + } + if !session.ever_crossed { + return Ok(Finish::Click(session.drag.target.id())); + } + Ok(match session.preview { + None => Finish::Noop, + Some(preview) => Finish::Commit { + command: Box::new(preview.command), + selected: session.drag.target.id(), + scale: session.scale, + pan: session.pan, + }, + }) + } + pub(crate) fn accept_spacing_camera(&mut self, scale: f32, pan: Vector) { + self.presentation.zoom = Some(scale); + self.presentation.pan = pan; + self.presentation.fit = false; + } +} diff --git a/crates/oa-desktop/src/behavior/scenario/spacing/controller/tests.rs b/crates/oa-desktop/src/behavior/scenario/spacing/controller/tests.rs new file mode 100644 index 00000000..dd3123cf --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/spacing/controller/tests.rs @@ -0,0 +1,421 @@ +use super::*; +use crate::document::Document; +use iced::Size; +use oa_core::{Diagram, Layer, ProjectionKind, language, source_project::ProjectLimits}; + +pub(crate) fn fixture() -> (Document, Id, Id, Id, Id) { + let mut p = language::parse(r#"arcadia 7; +project spacing "Spacing" { + oa_activity work "Work" {} + oa_activity response "Response" {} + oa_scenario incident "Incident" { viewpoint OperationalActivity; steps [@first, @second]; } + oa_lifeline reporter "Reporter" { parent @incident; represented @work; } + oa_lifeline operator "Operator" { parent @incident; represented @response; } + oa_occurrence first "First" { parent @incident; body { Exchange { exchange none; sender @reporter; receiver @operator; message_kind Asynchronous; }; }; } + oa_occurrence second "Second" { parent @incident; body { Exchange { exchange none; sender @operator; receiver @reporter; message_kind Asynchronous; }; }; } +}"#).unwrap(); + let id = |name: &str| { + p.identities() + .into_iter() + .find(|(_, alias)| *alias == name) + .unwrap() + .0 + }; + let (owner, lane, message) = (id("incident"), id("reporter"), id("first")); + let diagram = Id::new_v4(); + p.diagrams.insert( + diagram, + Diagram { + id: diagram, + alias: "saved".into(), + name: "Saved".into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + nodes: Default::default(), + routes: vec![], + view_options: Default::default(), + layout: None, + }, + ); + ( + Document::from_project(p, ProjectLimits::default()).unwrap(), + owner, + diagram, + lane, + message, + ) +} + +pub(crate) fn expected(p: &Project, diagram: Id, target: Target, delta: f32) -> Project { + capture(p, diagram, target) + .unwrap() + .prepare(delta) + .unwrap() + .unwrap() + .project +} + +pub(crate) fn packet( + p: &Project, + state: &State, + context: Context, + camera_epoch: u64, + target: Target, +) -> Event { + let geometry = resolve(p, context.representation, target.id()).unwrap(); + let world = match target { + Target::Lane(id) => geometry + .scene + .boxes + .iter() + .find(|item| item.id == id && item.role == SceneHitRole::LifelineHeader) + .unwrap() + .rect + .center(), + Target::Message(id) => { + let line = geometry + .scene + .lines + .iter() + .find(|line| line.id == id) + .unwrap(); + Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y) + } + }; + assert!( + geometry + .scene + .hit_item(world) + .is_some_and(|hit| hit.id == target.id()) + ); + let camera = state.presentation.camera(geometry.bounds); + let canvas_bounds = Rectangle::new(Point::new(17., 23.), state.presentation.viewport.unwrap()); + Event::Begin { + stamp: Stamp { + context, + camera_epoch, + }, + token: Id::new_v4(), + target, + press_screen: Point::new( + canvas_bounds.x + world.x * camera.scale + camera.pan.x, + canvas_bounds.y + world.y * camera.scale + camera.pan.y, + ), + canvas_bounds, + scale: camera.scale, + pan: camera.pan, + } +} +fn setup(target_message: bool) -> (Document, State, Event) { + let (doc, owner, diagram, lane, message) = fixture(); + let mut state = State::default(); + state.enter_representation(Id::new_v4(), owner, Representation::Saved { diagram }); + state.presentation.viewport = Some(Size::new(900., 600.)); + let context = state + .context( + state.presentation.identity.unwrap().0, + owner, + doc.revision(), + 4, + ) + .unwrap(); + let target = if target_message { + Target::Message(message) + } else { + Target::Lane(lane) + }; + let event = packet(doc.project(), &state, context, 9, target); + (doc, state, event) +} +fn press(event: Event) -> Point { + let Event::Begin { press_screen, .. } = event else { + unreachable!() + }; + press_screen +} + +#[test] +fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() { + for message in [false, true] { + let (doc, mut state, begin) = setup(message); + let original = doc.snapshot(); + let camera = state.camera_boundary(); + let start = press(begin); + state.begin_spacing(doc.project(), begin).unwrap(); + let first = if message { + Vector::new(500., 30.) + } else { + Vector::new(30., 500.) + }; + state.move_spacing(start + first); + let session = state.spacing.as_ref().unwrap(); + let candidate = session.preview.as_ref().unwrap(); + let delta = 30. / session.scale; + let actual_anchor = match session.drag.target { + Target::Lane(id) => candidate.geometry.scene.render_lanes[&id], + Target::Message(id) => { + candidate + .geometry + .scene + .lines + .iter() + .find(|line| line.id == id) + .unwrap() + .points[0] + .y + } + }; + let wanted = (f64::from(session.drag.origin) + f64::from(delta)) as f32; + assert_eq!(actual_anchor.to_bits(), wanted.to_bits()); + let expected = session.drag.prepare(delta).unwrap().unwrap(); + assert_eq!(candidate.project, expected.project); + assert_eq!(candidate.geometry.scene.rows, expected.geometry.scene.rows); + assert_eq!( + candidate.geometry.scene.render_lanes, + expected.geometry.scene.render_lanes + ); + state.move_spacing(start + first * 2.); + let session = state.spacing.as_ref().unwrap(); + assert_eq!( + session.preview.as_ref().unwrap().project, + session.drag.prepare(delta * 2.).unwrap().unwrap().project + ); + assert_eq!(doc.project(), &original.decoded.project); + assert_eq!(doc.compiled().bundle, original.bundle); + assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo()); + assert!(camera == state.camera_boundary()); + } +} + +#[test] +fn click_threshold_is_active_axis_and_return_to_zero_is_not_a_click() { + let (doc, mut state, begin) = setup(false); + let start = press(begin); + state.begin_spacing(doc.project(), begin).unwrap(); + state.move_spacing(start + Vector::new(2., 100.)); + assert!(matches!( + state.finish_spacing(start).unwrap(), + Finish::Click(_) + )); + state.begin_spacing(doc.project(), begin).unwrap(); + state.move_spacing(start + Vector::new(4., 0.)); + assert!(matches!(state.finish_spacing(start).unwrap(), Finish::Noop)); + assert!(!doc.can_undo()); +} + +#[test] +fn invalid_move_discards_prior_candidate_and_finish_rechecks_final_pointer() { + let (doc, mut state, begin) = setup(false); + let start = press(begin); + state.begin_spacing(doc.project(), begin).unwrap(); + state.move_spacing(start + Vector::new(20., 0.)); + state.move_spacing(Point::new(f32::NAN, start.y)); + let paint = state.spacing_paint(begin.stamp().context, 9).unwrap(); + assert!(matches!(paint.refusal, Some(Refusal::InvalidDelta))); + assert_eq!( + paint.scene.render_lanes, + state + .spacing + .as_ref() + .unwrap() + .drag + .original_geometry + .scene + .render_lanes + ); + assert!(matches!( + state.finish_spacing(Point::new(f32::NAN, start.y)), + Err(Refusal::InvalidDelta) + )); + assert!(state.spacing_token().is_none()); +} + +#[test] +fn forged_camera_hit_role_and_retired_visit_cannot_capture() { + let (doc, mut state, begin) = setup(false); + let Event::Begin { + stamp, + token, + target, + press_screen, + canvas_bounds, + scale, + pan, + } = begin + else { + unreachable!() + }; + assert!(matches!( + state.begin_spacing( + doc.project(), + Event::Begin { + stamp, + token, + target, + press_screen, + canvas_bounds, + scale: scale * 2., + pan + } + ), + Err(Refusal::CameraChanged) + )); + assert!(matches!( + state.begin_spacing( + doc.project(), + Event::Begin { + stamp, + token, + target, + press_screen: canvas_bounds.center(), + canvas_bounds, + scale, + pan + } + ), + Err(Refusal::TargetNotHit(_)) + )); + state.leave(); + assert!(matches!( + state.begin_spacing(doc.project(), begin), + Err(Refusal::InvalidGesture) + )); +} + +#[test] +fn zoomed_out_message_tolerance_matches_actual_canvas_hit() { + let (doc, mut state, _) = setup(true); + state.presentation.fit = false; + state.presentation.zoom = Some(0.25); + state.presentation.pan = Vector::ZERO; + let context = state + .context( + state.presentation.identity.unwrap().0, + state.owner().unwrap(), + doc.revision(), + 4, + ) + .unwrap(); + let message = doc + .project() + .identities() + .into_iter() + .find(|(_, alias)| *alias == "first") + .map(|(id, _)| id) + .unwrap(); + let Event::Begin { + stamp, + token, + target, + press_screen, + canvas_bounds, + scale, + pan, + } = packet(doc.project(), &state, context, 9, Target::Message(message)) + else { + unreachable!() + }; + let event = Event::Begin { + stamp, + token, + target, + press_screen: press_screen + Vector::new(0., 3.), + canvas_bounds, + scale, + pan, + }; + state.begin_spacing(doc.project(), event).unwrap(); + assert_eq!(state.spacing_token(), Some(token)); +} + +#[test] +fn fractional_screen_packet_preserves_subpixel_motion_in_preview_and_finish() { + let (doc, owner, diagram, _, message) = fixture(); + let original = doc.snapshot(); + assert!(doc.project().diagrams[&diagram].layout.is_none()); + let mut state = State::default(); + let document = Id::new_v4(); + state.enter_representation(document, owner, Representation::Saved { diagram }); + state.presentation.viewport = Some(Size::new(900., 600.)); + state.presentation.fit = false; + state.presentation.zoom = Some(0.5); + // 406 + 2^-15 puts the press just below the 512 screen-coordinate boundary. + state.presentation.pan = Vector::new(0., f32::from_bits(0x43cb_0001)); + let camera = state.camera_boundary(); + let context = state.context(document, owner, doc.revision(), 4).unwrap(); + let baseline = resolve(doc.project(), context.representation, message).unwrap(); + let line = baseline + .scene + .lines + .iter() + .find(|line| line.id == message) + .unwrap(); + assert_eq!(line.points[0].y, 148.); + let begin = packet(doc.project(), &state, context, 9, Target::Message(message)); + let Event::Begin { + press_screen, + canvas_bounds, + scale, + pan, + .. + } = begin + else { + unreachable!() + }; + assert_eq!(canvas_bounds.y, 23.); + assert_eq!(scale, 0.5); + assert_eq!(pan.y.to_bits(), 0x43cb_0001); + assert_eq!(press_screen.y.to_bits(), 0x43fb_8001); // 503 + 2^-15. + let end = press_screen + Vector::new(0., 10.); + assert_eq!(end.y, 513.); + assert!(canvas_bounds.contains(press_screen) && canvas_bounds.contains(end)); + // The submitted f32 displacement is 10 - 2^-15, not exactly 10. + assert_eq!((end.y - press_screen.y).to_bits(), 0x411f_ffe0); + assert_eq!(((end.y - press_screen.y) / scale).to_bits(), 0x419f_ffe0); + // Independent oracle: 148 + (10 - 2^-15) / .5 = 168 - 2^-14. + let expected_y = f32::from_bits(0x4327_fffc); + let mut expected = original.decoded.project.clone(); + expected.diagrams.get_mut(&diagram).unwrap().layout = + Some(oa_core::DiagramLayout::Scenario(oa_core::ScenarioLayout { + lanes: vec![], + messages: vec![oa_core::ScenarioMessagePosition { + address: oa_core::OccurrenceEndpoint { + references: vec![], + occurrence: message, + }, + send_y: expected_y, + }], + })); + state.begin_spacing(doc.project(), begin).unwrap(); + state.move_spacing(end); + let preview = state.spacing.as_ref().unwrap().preview.as_ref().unwrap(); + assert_eq!(preview.project, expected); + let line = preview + .geometry + .scene + .lines + .iter() + .find(|line| line.id == message) + .unwrap(); + assert_eq!(line.points[0].y.to_bits(), expected_y.to_bits()); + let Finish::Commit { + command, + selected, + scale, + pan, + } = state.finish_spacing(end).unwrap() + else { + panic!("fractional motion must remain a commit") + }; + assert_eq!(selected, message); + assert_eq!(scale, 0.5); + assert_eq!(pan.y.to_bits(), 0x43cb_0001); + let committed = oa_core::execute_batch(doc.project(), &[*command]).unwrap(); + assert_eq!(committed, expected); + assert!(state.spacing_token().is_none()); + assert_eq!(doc.project(), &original.decoded.project); + assert_eq!(doc.compiled().bundle, original.bundle); + assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo()); + assert!(camera == state.camera_boundary()); +} diff --git a/crates/oa-desktop/src/behavior/scenario/spacing/controller/timing_tests.rs b/crates/oa-desktop/src/behavior/scenario/spacing/controller/timing_tests.rs new file mode 100644 index 00000000..49c396d5 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/spacing/controller/timing_tests.rs @@ -0,0 +1,304 @@ +//! Diagnostic only: production capture/prepare, excluding fixture construction, +//! oracle work, App source writes, widget rebuilds, text shaping and GPU paint. +use super::*; +use crate::document::Document; +use oa_core::{ + DiagramLayout, Layer, MessageKind, RelationKind, ScenarioLayout, source_project::ProjectLimits, +}; +use serde_json::{Value, json}; +use std::{collections::BTreeSet, sync::Arc, time::Instant}; + +const MESSAGE_COUNT: usize = 1000; +const TARGET_INDEX: usize = 500; + +fn large_fixture() -> (Document, Id, [Id; 3], Vec, Vec) { + let (seed, owner, a, reporter, first) = super::tests::fixture(); + let mut p = seed.project().clone(); + let old_steps = p.elements[&owner].steps().unwrap().clone(); + let template = p.elements[&first].clone(); + let work = |alias: &str| { + p.identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 + }; + let (source, target) = (work("work"), work("response")); + let operator = work("operator"); + let relation = p.relate( + RelationKind::Interaction, + "Repeated incident report", + source, + target, + ); + let mut lanes = vec![]; + for index in 0..20 { + let original = if index < 10 { reporter } else { operator }; + let mut lane = p.elements[&original].clone(); + lane.id = Id::new_v4(); + lane.alias = format!("timing_lane_{index:02}"); + lane.name = format!("Review participant {index:02}"); + lanes.push(lane.id); + p.elements.insert(lane.id, lane); + } + p.elements.remove(&reporter); + p.elements.remove(&operator); + for id in old_steps { + p.elements.remove(&id); + } + let mut messages = vec![]; + for index in 0..MESSAGE_COUNT { + let mut occurrence = template.clone(); + occurrence.id = Id::new_v4(); + occurrence.alias = format!("timing_message_{index:04}"); + occurrence.name = format!("Validate incident report {index:04}"); + *occurrence.shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(relation), + sender: Some(lanes[index % 10]), + receiver: Some(lanes[10 + index % 10]), + message_kind: MessageKind::Asynchronous, + operation: None, + reply_to: None, + values: vec![], + }, + precondition: String::new(), + postcondition: String::new(), + }; + messages.push(occurrence.id); + p.elements.insert(occurrence.id, occurrence); + } + *p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = messages.clone(); + p.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: lanes + .iter() + .enumerate() + .map(|(i, &lifeline)| ScenarioLanePosition { + lifeline, + x: 160. + i as f32 * 240., + }) + .collect(), + messages: vec![], + })); + let mut views = [a, Id::nil(), Id::nil()]; + for (index, x, offset) in [(1, -500., 12.), (2, 6500., -12.)] { + let mut view = p.diagrams[&a].clone(); + view.id = Id::new_v4(); + view.alias = format!("timing_saved_{index}"); + view.name = format!("Review arrangement {index}"); + view.layout = Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: vec![ScenarioLanePosition { + lifeline: lanes[0], + x, + }], + messages: vec![ScenarioMessagePosition { + address: OccurrenceEndpoint { + references: vec![], + occurrence: messages[TARGET_INDEX], + }, + send_y: 148. + TARGET_INDEX as f32 * 92. + offset, + }], + })); + views[index] = view.id; + p.diagrams.insert(view.id, view); + } + let document = Document::from_project(p, ProjectLimits::default()).unwrap(); + assert_eq!( + document + .project() + .children(owner) + .filter(|e| e.kind() == Kind::Lifeline) + .count(), + 20 + ); + assert_eq!(document.project().elements[&owner].layer(), Layer::Oa); + assert_eq!(document.project().diagrams.len(), 3); + (document, owner, views, lanes, messages) +} + +fn assert_scene( + scene: &Scene, + lanes: &[Id], + messages: &[Id], + moved_message: Option<(Id, f32)>, + moved_lane: Option<(Id, f32)>, +) { + assert_eq!(scene.render_lanes.len(), 20); + assert_eq!( + scene + .boxes + .iter() + .filter(|b| b.role == SceneHitRole::LifelineHeader) + .count(), + 20 + ); + assert_eq!(scene.lines.len(), MESSAGE_COUNT); + assert_eq!( + scene + .lines + .iter() + .map(|line| line.id) + .collect::>(), + messages.iter().copied().collect::>() + ); + assert!(scene.activations.is_empty() && scene.open_calls.is_empty()); + let x = |index: usize| { + 160. + index as f32 * 240. + + moved_lane + .filter(|(id, _)| *id == lanes[index]) + .map_or(0., |(_, delta)| delta) + }; + for (index, &lane) in lanes.iter().enumerate() { + assert_eq!(scene.render_lanes[&lane], x(index)); + } + for (index, (line, &id)) in scene.lines.iter().zip(messages).enumerate() { + let y = 148. + + index as f32 * 92. + + moved_message + .filter(|(target, _)| *target == id) + .map_or(0., |(_, delta)| delta); + assert_eq!(line.id, id); + assert_eq!( + line.points, + vec![ + Point::new(x(index % 10), y), + Point::new(x(10 + index % 10), y) + ] + ); + } +} + +fn summary(samples: &[Value], phase: &str) -> Value { + let mut times: Vec = samples + .iter() + .filter(|sample| sample["phase"] == phase) + .map(|sample| sample["elapsed_ns"].as_u64().unwrap()) + .collect(); + times.sort_unstable(); + let n = times.len(); + let median = if n.is_multiple_of(2) { + ((u128::from(times[n / 2 - 1]) + u128::from(times[n / 2])) / 2) as u64 + } else { + times[n / 2] + }; + json!({"samples": n, "min_ns": times[0], "median_ns": median, "max_ns": times[n-1]}) +} + +#[test] +#[ignore = "Diagnostic timing only; run alone with --exact --ignored --nocapture after correctness gates; no latency acceptance threshold"] +fn saved_spacing_capture_prepare_20_lanes_1000_messages_3_views() { + let (document, owner, views, lanes, messages) = large_fixture(); + let original = document.snapshot(); + let target = messages[TARGET_INDEX]; + let mut samples = vec![]; + let mut retained = None; + for iteration in 0..4 { + let start = Instant::now(); + let result = capture(document.project(), views[0], Target::Message(target)); + let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap(); + let drag = result.expect("complete asynchronous fixture must resolve"); + samples.push(json!({"phase":"capture_message", "iteration":iteration, + "first_in_probe":iteration==0, "elapsed_ns":elapsed_ns, "outcome":"captured"})); + assert_scene(&drag.original_geometry.scene, &lanes, &messages, None, None); + if iteration == 0 { + retained = Some(drag); + } + } + let drag = retained.unwrap(); + // Every sample is relative to the original press, including duplicate, + // reverse and return-to-start samples. Oracle work is outside each timer. + let deltas = [ + 4., 8., 12., 16., 20., 24., 28., 32., 32., 28., 24., 20., 16., 12., 8., 4., -4., -8., -4., + 0., + ]; + for (iteration, delta) in deltas.into_iter().enumerate() { + let start = Instant::now(); + let result = drag.prepare(delta); + let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap(); + let candidate = result.expect("these isolated asynchronous offsets are feasible"); + samples.push(json!({"phase":"prepare_message", "iteration":iteration, + "delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":if candidate.is_some(){"preview"}else{"noop"}})); + if delta == 0. { + assert!(candidate.is_none()); + } else { + let candidate = candidate.unwrap(); + let mut expected = original.decoded.project.clone(); + let Some(DiagramLayout::Scenario(layout)) = + &mut expected.diagrams.get_mut(&views[0]).unwrap().layout + else { + panic!() + }; + layout.messages.push(ScenarioMessagePosition { + address: OccurrenceEndpoint { + references: vec![], + occurrence: target, + }, + send_y: 148. + TARGET_INDEX as f32 * 92. + delta, + }); + assert_eq!(candidate.project, expected); + assert_scene( + &candidate.geometry.scene, + &lanes, + &messages, + Some((target, delta)), + None, + ); + } + assert_eq!( + document.project().elements[&owner].steps().unwrap(), + &messages + ); + assert_eq!(document.project(), &original.decoded.project); + assert!(Arc::ptr_eq(&original, &document.snapshot())); + } + let lane = lanes[10]; + let start = Instant::now(); + let result = capture(document.project(), views[0], Target::Lane(lane)); + let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap(); + let drag = result.unwrap(); + samples.push(json!({"phase":"capture_lane", "iteration":0, "elapsed_ns":elapsed_ns, "outcome":"captured"})); + for (iteration, delta) in [20., 40., -20.].into_iter().enumerate() { + let start = Instant::now(); + let result = drag.prepare(delta); + let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap(); + let candidate = result.unwrap().unwrap(); + samples.push(json!({"phase":"prepare_lane", "iteration":iteration, + "delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":"preview"})); + let mut expected = original.decoded.project.clone(); + let Some(DiagramLayout::Scenario(layout)) = + &mut expected.diagrams.get_mut(&views[0]).unwrap().layout + else { + panic!() + }; + layout.lanes[10].x = 2560. + delta; + assert_eq!(candidate.project, expected); + assert_scene( + &candidate.geometry.scene, + &lanes, + &messages, + None, + Some((lane, delta)), + ); + } + assert_eq!(document.project(), &original.decoded.project); + assert_eq!(document.bundle(), &original.bundle); + for view in views { + assert_eq!( + document.project().diagrams[&view], + original.decoded.project.diagrams[&view] + ); + } + assert_eq!(document.revision(), 0); + assert!(!document.dirty() && !document.can_undo() && !document.can_redo()); + let report = json!({ + "schema":"scenario_spacing_timing_v1", + "fixture":{"lanes":20,"messages":MESSAGE_COUNT,"saved_views":3,"target_index":TARGET_INDEX,"distinct_message_ids":messages.iter().copied().collect::>().len()}, + "profile_context":{"debug_assertions":cfg!(debug_assertions),"os":std::env::consts::OS,"arch":std::env::consts::ARCH, + "source_pin_from_runner":std::env::var("SCENARIO_SPACING_SOURCE_PIN").ok(),"source_binary_toolchain_binding":"external gate receipt required"}, + "summaries":{"capture_message":summary(&samples,"capture_message"),"prepare_message":summary(&samples,"prepare_message"),"capture_lane":summary(&samples,"capture_lane"),"prepare_lane":summary(&samples,"prepare_lane")}, + "samples":samples, + "invariant_status":"passed", + "limits":"First capture is first in this probe, not cold OS caches. Timers exclude construction/oracles, prior-preview disposal, source write/history, widget rebuild, label shaping, GPU paint and queued input; no responsiveness acceptance or latency threshold. All messages asynchronous; no nested frame/lifecycle stress." + }); + println!("SPACING_TIMING_JSON={report}"); +} diff --git a/crates/oa-desktop/src/behavior/scenario/spacing/tests.rs b/crates/oa-desktop/src/behavior/scenario/spacing/tests.rs new file mode 100644 index 00000000..3fcda05e --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/spacing/tests.rs @@ -0,0 +1,522 @@ +use super::*; +use crate::behavior::scenario_layout::Diagnostic; +use crate::document::Document; +use oa_core::{ + Diagram, DiagramLayout, ProjectionKind, ScenarioLayout, language, source_project::ProjectLimits, +}; + +fn id(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} + +fn fixture(layout: Option) -> (Document, Id, Id) { + let mut project = language::parse(FIXTURE).unwrap(); + let owner = id(&project, "conversation"); + let mut add = |alias: &str, layout: Option| { + let diagram = Id::new_v4(); + project.diagrams.insert( + diagram, + Diagram { + id: diagram, + alias: alias.into(), + name: alias.into(), + description: String::new(), + layer: oa_core::Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + nodes: Default::default(), + routes: vec![], + view_options: Default::default(), + layout: layout.map(DiagramLayout::Scenario), + }, + ); + diagram + }; + let a = add("saved_a", layout); + let b = add("saved_b", Some(ScenarioLayout::default())); + let document = Document::from_project(project, ProjectLimits::default()).unwrap(); + (document, a, b) +} + +fn resolved(project: &Project, diagram: Id, selected: Id) -> ResolvedScenarioPlan { + resolve(project, Representation::Saved { diagram }, selected).unwrap() +} + +fn same_geometry(a: &ResolvedScenarioPlan, b: &ResolvedScenarioPlan) { + assert_eq!(a.bounds, b.bounds); + assert_eq!(a.lanes, b.lanes); + assert_eq!(a.plan.frames, b.plan.frames); + assert_eq!(a.plan.scopes, b.plan.scopes); + let (a, b) = (&a.scene, &b.scene); + assert_eq!(a.rows, b.rows); + assert_eq!(a.sequence_bounds, b.sequence_bounds); + assert_eq!(a.size, b.size); + assert_eq!(a.layout_origin, b.layout_origin); + assert_eq!(a.lanes, b.lanes); + assert_eq!(a.render_lanes, b.render_lanes); + assert_eq!(a.activations, b.activations); + assert_eq!(a.lifecycle, b.lifecycle); + assert_eq!(a.open_calls, b.open_calls); + let slots = |scene: &crate::behavior::Scene| { + scene + .slots + .iter() + .map(|item| { + ( + item.slot.parent, + item.slot.before, + item.y, + item.label.clone(), + ) + }) + .collect::>() + }; + assert_eq!(slots(a), slots(b)); + let lines = |scene: &crate::behavior::Scene| { + scene + .lines + .iter() + .map(|line| { + ( + line.id, + line.points.clone(), + line.label_at, + line.label.clone(), + line.dashed, + ) + }) + .collect::>() + }; + let boxes = |scene: &crate::behavior::Scene| { + scene + .boxes + .iter() + .map(|item| { + ( + item.id, + item.rect, + item.title.clone(), + item.detail.clone(), + item.marker, + ) + }) + .collect::>() + }; + assert_eq!(lines(a), lines(b)); + assert_eq!(boxes(a), boxes(b)); +} + +fn expected_message(project: &Project, diagram: Id, occurrence: Id, y: f32) -> Project { + // Independent expected mutation: tests begin with no message overrides. + let mut expected = project.clone(); + expected.diagrams.get_mut(&diagram).unwrap().layout = + Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: vec![], + messages: vec![ScenarioMessagePosition { + address: OccurrenceEndpoint { + references: vec![], + occurrence, + }, + send_y: y, + }], + })); + expected +} + +#[test] +fn one_repeated_message_moves_in_one_view_with_exact_document_undo_redo_and_reopen() { + let (mut document, a, b) = fixture(None); + let original = document.snapshot(); + let before = document.project().clone(); + let first = id(&before, "first"); + let second = id(&before, "second"); + let baseline = resolved(&before, a, second); + assert_eq!(baseline.scene.rows[&first].1, 148.); + assert_eq!(baseline.scene.rows[&second].1, 240.); + let untouched = resolved(&before, b, second); + let drag = capture(&before, a, Target::Message(second)).unwrap(); + let preview = drag.prepare(-40.).unwrap().unwrap(); + let expected = expected_message(&before, a, second, 200.); + assert_eq!(preview.project, expected); + assert_eq!(preview.geometry.scene.rows[&first].1, 148.); + assert_eq!(preview.geometry.scene.rows[&second].1, 200.); + let line = preview + .geometry + .scene + .lines + .iter() + .find(|line| line.id == second) + .unwrap(); + assert_eq!(line.points.first().unwrap().y, 200.); + assert_eq!(line.points.last().unwrap().y, 200.); + assert_eq!( + preview.geometry.scene.activations, + baseline.scene.activations + ); + assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle); + same_geometry(&resolved(&preview.project, b, second), &untouched); + assert_eq!(drag.original, before); + assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot())); + assert!(!document.can_undo()); + + let returned_selection = document.execute(preview.command).unwrap(); + assert_eq!( + returned_selection, + Some(a), + "controller must preserve the dragged semantic selection separately" + ); + assert_eq!(document.project(), &expected); + assert_eq!(document.revision(), 1); + let accepted = document.bundle().clone(); + same_geometry(&resolved(document.project(), a, second), &preview.geometry); + assert!(document.undo()); + assert_eq!(document.project(), &before); + assert_eq!(document.bundle(), &original.bundle); + assert!(!document.can_undo()); + assert!(document.redo()); + assert_eq!(document.project(), &expected); + assert_eq!(document.bundle(), &accepted); + let reopened = Document::from_bundle(accepted, ProjectLimits::default()).unwrap(); + assert_eq!(reopened.project(), &expected); + same_geometry(&resolved(reopened.project(), a, second), &preview.geometry); + assert!(!reopened.dirty() && !reopened.can_undo()); +} + +#[test] +fn horizontal_lane_move_changes_actual_header_and_attached_endpoints_only_in_selected_view() { + let (document, a, b) = fixture(None); + let before = document.project(); + let right = id(before, "right"); + let first = id(before, "first"); + let other = resolved(before, b, right); + let preview = capture(before, a, Target::Lane(right)) + .unwrap() + .prepare(100.) + .unwrap() + .unwrap(); + let mut expected = before.clone(); + expected.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: vec![ScenarioLanePosition { + lifeline: right, + x: 500., + }], + messages: vec![], + })); + assert_eq!(preview.project, expected); + assert_eq!(preview.geometry.scene.lanes[&right], 500.); + let header = preview + .geometry + .scene + .boxes + .iter() + .find(|item| item.id == right) + .unwrap(); + assert_eq!(header.rect.center_x(), 500.); + let line = preview + .geometry + .scene + .lines + .iter() + .find(|line| line.id == first) + .unwrap(); + assert_eq!(line.points.first().unwrap().x, 160.); + assert_eq!(line.points.last().unwrap().x, 500.); + assert!(line.points.iter().all(|point| point.y == 148.)); + same_geometry(&resolved(&preview.project, b, right), &other); + assert_eq!(document.project(), before); + assert!(!document.dirty() && !document.can_undo()); +} + +#[test] +fn drawn_unassigned_lifeline_can_move_without_assigning_a_semantic_participant() { + let (document, a, _) = fixture(None); + let mut project = document.project().clone(); + let owner = id(&project, "conversation"); + let lane = project + .add_element( + Kind::Lifeline, + oa_core::Layer::Oa, + "Unassigned header", + Some(owner), + ) + .unwrap(); + let document = Document::from_project(project, ProjectLimits::default()).unwrap(); + let baseline = resolved(document.project(), a, lane); + assert!(!baseline.scene.lanes.contains_key(&lane)); + let before_header = baseline + .scene + .boxes + .iter() + .find(|item| item.id == lane) + .unwrap() + .rect; + let preview = capture(document.project(), a, Target::Lane(lane)) + .unwrap() + .prepare(70.) + .unwrap() + .unwrap(); + let after_header = preview + .geometry + .scene + .boxes + .iter() + .find(|item| item.id == lane) + .unwrap() + .rect; + assert_eq!(after_header.x, before_header.x + 70.); + assert_eq!(after_header.y, before_header.y); + assert_eq!(after_header.size(), before_header.size()); + assert_eq!(preview.project.elements, document.project().elements); + assert_eq!(preview.project.relations, document.project().relations); + assert!(!document.can_undo()); +} + +#[test] +fn self_call_spacing_uses_send_y_and_preserves_reply_and_actual_activation_finish() { + let (document, a, _) = fixture(None); + let before = document.project(); + let call = id(before, "call"); + let reply = id(before, "reply"); + let baseline = resolved(before, a, call); + let preview = capture(before, a, Target::Message(call)) + .unwrap() + .prepare(-22.) + .unwrap() + .unwrap(); + assert_eq!(preview.project, expected_message(before, a, call, 310.)); + let line = preview + .geometry + .scene + .lines + .iter() + .find(|line| line.id == call) + .unwrap(); + assert_eq!(line.points.first().unwrap().y, 310.); + assert_eq!(line.points.last().unwrap().y, 334.); + assert_eq!(preview.geometry.scene.rows[&reply].1, 424.); + assert_eq!(preview.geometry.scene.activations.len(), 1); + let mut expected_activation = baseline.scene.activations.clone(); + expected_activation[0].rect.y = 334.; + expected_activation[0].rect.height = 90.; + assert_eq!(preview.geometry.scene.activations, expected_activation); + assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle); +} + +#[test] +fn feasible_spacing_crossing_remains_authored_with_diagnostic_and_unchanged_steps() { + let (document, a, _) = fixture(None); + let before = document.project(); + let first = id(before, "first"); + let second = id(before, "second"); + let owner = id(before, "conversation"); + let preview = capture(before, a, Target::Message(second)) + .unwrap() + .prepare(-120.) + .unwrap() + .unwrap(); + assert_eq!(preview.project, expected_message(before, a, second, 120.)); + assert_eq!(preview.geometry.scene.rows[&second].1, 120.); + assert_eq!(preview.geometry.scene.rows[&first].1, 148.); + assert_eq!( + preview.project.elements[&owner].steps(), + before.elements[&owner].steps() + ); + let shown = crate::behavior::scenario_layout::presentation::present( + &preview.project, + Representation::Saved { diagram: a }, + second, + ); + assert!(shown.capabilities().coordinate_input); + assert!( + shown + .diagnostics() + .contains(&Diagnostic::SourceOrderCrossing { + scope: owner, + before: first, + after: second + }) + ); +} + +#[test] +fn unsupported_self_call_move_is_refused_and_automatic_fallback_is_not_editable_capture() { + let (document, a, _) = fixture(None); + let before = document.project(); + let call = id(before, "call"); + let drag = capture(before, a, Target::Message(call)).unwrap(); + let Err(Refusal::Geometry(issues)) = drag.prepare(118.) else { + panic!("send450 / receive474 after reply424 must be refused") + }; + assert!(issues.iter().any(|issue| matches!(issue, GeometryIssue::TypedActivationCrossingNotSupported { call: id, .. } if *id == call))); + assert_eq!(&drag.original, before); + let invalid_geometry = expected_message(before, a, call, 450.); + let shown = crate::behavior::scenario_layout::presentation::present( + &invalid_geometry, + Representation::Saved { diagram: a }, + call, + ); + assert!(shown.capabilities().geometry_navigation && !shown.capabilities().coordinate_input); + assert!(matches!( + capture(&invalid_geometry, a, Target::Message(call)), + Err(Refusal::Geometry(_)) + )); + assert!(!document.dirty() && !document.can_undo()); +} + +#[test] +fn invalid_view_foreign_identity_and_latent_message_are_not_spacing_targets() { + let (document, a, b) = fixture(None); + let p = document.project(); + let unfinished = id(p, "unfinished"); + let source = id(p, "source"); + assert!(matches!( + capture(p, Id::nil(), Target::Lane(source)), + Err(Refusal::InvalidSavedView(_)) + )); + for target in [ + Target::Lane(source), + Target::Lane(b), + Target::Message(source), + Target::Message(unfinished), + ] { + assert!( + matches!(capture(p, a, target), Err(Refusal::TargetNotRendered(found)) if found == target) + ); + } + // A stored latent coordinate is not permission to fabricate a painted arrow. + let mut latent = expected_message(p, a, unfinished, 2000.); + let foreign_owner = latent + .add_element(Kind::Scenario, oa_core::Layer::Oa, "Foreign", None) + .unwrap(); + let foreign_lane = latent + .add_element( + Kind::Lifeline, + oa_core::Layer::Oa, + "Foreign lane", + Some(foreign_owner), + ) + .unwrap(); + *latent + .elements + .get_mut(&foreign_lane) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Lifeline { + represented: Some(source), + }; + let latent_document = Document::from_project(latent, ProjectLimits::default()).unwrap(); + assert!(matches!( + capture(latent_document.project(), a, Target::Message(unfinished)), + Err(Refusal::TargetNotRendered(_)) + )); + assert!(matches!( + capture(latent_document.project(), a, Target::Lane(foreign_lane)), + Err(Refusal::TargetNotRendered(_)) + )); +} + +#[test] +fn zero_motion_return_to_start_and_subprecision_preserve_sparse_intent_and_real_redo() { + let parsed = language::parse(FIXTURE).unwrap(); + let left = id(&parsed, "left"); + let second = id(&parsed, "second"); + let layouts = [ + None, + Some(ScenarioLayout::default()), + Some(ScenarioLayout { + lanes: vec![], + messages: vec![ScenarioMessagePosition { + address: OccurrenceEndpoint { + references: vec![], + occurrence: second, + }, + send_y: 240., + }], + }), + Some(ScenarioLayout { + lanes: vec![ScenarioLanePosition { + lifeline: left, + x: -0., + }], + messages: vec![], + }), + ]; + for (index, layout) in layouts.into_iter().enumerate() { + let (mut document, a, _) = fixture(layout); + let owner = id(document.project(), "conversation"); + document + .execute(Command::Rename(owner, "A real redo entry".into())) + .unwrap(); + let redo = document.snapshot(); + assert!(document.undo()); + let before = document.snapshot(); + let revision = document.revision(); + let target = if index == 3 { + Target::Lane(left) + } else { + Target::Message(second) + }; + let drag = capture(document.project(), a, target).unwrap(); + if index == 3 { + let Some(DiagramLayout::Scenario(layout)) = &drag.original.diagrams[&a].layout else { + panic!() + }; + assert_eq!(layout.lanes[0].x.to_bits(), (-0_f32).to_bits()); + } + assert!(drag.prepare(10.).unwrap().is_some()); + for delta in [0., -0.] { + assert!(drag.prepare(delta).unwrap().is_none()); + } + if index != 3 { + assert!(drag.prepare(f32::from_bits(1)).unwrap().is_none()); + } + assert_eq!( + serde_json::to_vec(&drag.original).unwrap(), + serde_json::to_vec(document.project()).unwrap() + ); + assert!(std::sync::Arc::ptr_eq(&before, &document.snapshot())); + assert_eq!(document.bundle(), &before.bundle); + assert_eq!(document.revision(), revision); + assert!(document.can_redo()); + assert!(document.redo()); + assert_eq!(document.project(), &redo.decoded.project); + assert_eq!(document.bundle(), &redo.bundle); + } +} + +#[test] +fn malformed_or_out_of_range_motion_cannot_mutate_original() { + let (document, a, _) = fixture(None); + let drag = capture( + document.project(), + a, + Target::Message(id(document.project(), "second")), + ) + .unwrap(); + for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta))); + } + assert!(matches!(drag.prepare(f32::MAX), Err(Refusal::Command(_)))); + assert_eq!(&drag.original, document.project()); + assert!(!document.dirty() && !document.can_undo()); +} + +const FIXTURE: &str = r#"arcadia 7; +project spacing_probe "Saved spacing probe" id "71000000-0000-4000-8000-000000000001" { + oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {} + oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {} + interaction exchange "Repeated exchange" id "71000000-0000-4000-8000-000000000004" { source @source; target @target; } + oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; } + oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; } + oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; } + oa_occurrence first "First" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; } + oa_occurrence second "Second" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; } + oa_occurrence call "Call" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; } + oa_occurrence reply "Reply" id "71000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; } + oa_occurrence unfinished "Unfinished" id "71000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; } +} +"#; diff --git a/crates/oa-desktop/src/behavior/scenario/usage.rs b/crates/oa-desktop/src/behavior/scenario/usage.rs index 824375b3..3ff5b88a 100644 --- a/crates/oa-desktop/src/behavior/scenario/usage.rs +++ b/crates/oa-desktop/src/behavior/scenario/usage.rs @@ -1,5 +1,6 @@ //! Navigation is derived from the same opaque-reference scene that is painted. use super::*; +use crate::behavior::scenario_layout::{Representation, presentation}; use crate::diagram_usage::{Surface, Target, Usage}; /// The renderer collapses whitespace before shortening. Require at least one @@ -22,7 +23,14 @@ fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool { start < retained } -fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec { +fn usages( + p: &Project, + owner: Id, + subject: Id, + surface: Surface, + bounds: Rectangle, + scene: &Scene, +) -> Vec { let mut result = Vec::new(); let mut add = |id, bounds, role: &str| { // Activation bars share the occurrence identity with a labelled message; @@ -33,7 +41,7 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec { { result.push(Usage { subject, - surface: Surface::Scenario(owner), + surface, target: Target::ScenarioObject(id), role: role.into(), bounds, @@ -137,15 +145,15 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec { } } } - if subject == owner { + if subject == owner || subject == surface.id() { result.insert( 0, Usage { subject, - surface: Surface::Scenario(owner), + surface, target: Target::Whole, role: "scenario".into(), - bounds: Rectangle::new(Point::ORIGIN, scene.size), + bounds, }, ); } @@ -153,15 +161,29 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec { } impl State { - pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec { - if !p - .elements - .get(&owner) - .is_some_and(|e| e.kind() == Kind::Scenario) - { + /// Membership comes from the complete presentation chosen for this exact view, + /// including automatic fallback geometry while retaining its Saved identity. + pub(crate) fn representation_usages( + &self, + p: &Project, + representation: Representation, + subject: Id, + ) -> Vec { + let shown = presentation::present(p, representation, subject); + let (Some(owner), Some(bounds), Some(scene)) = + (shown.owner(), shown.bounds(), shown.scene()) + else { return vec![]; - } - usages(p, owner, subject, &Scene::sequence(p, owner, subject)) + }; + let surface = match representation { + Representation::Derived { owner } => Surface::Scenario(owner), + Representation::Saved { diagram } => Surface::Saved(diagram), + }; + usages(p, owner, subject, surface, bounds, scene) + } + + pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec { + self.representation_usages(p, Representation::Derived { owner }, subject) } pub(crate) fn reveal_usage( @@ -171,12 +193,24 @@ impl State { owner: Id, target: &Target, ) -> bool { - if self.draft.is_some() - || !p - .elements - .get(&owner) - .is_some_and(|e| e.kind() == Kind::Scenario) - { + self.reveal_representation_usage( + p, + document, + owner, + Representation::Derived { owner }, + target, + ) + } + + pub(crate) fn reveal_representation_usage( + &mut self, + p: &Project, + document: Id, + owner: Id, + representation: Representation, + target: &Target, + ) -> bool { + if self.draft.is_some() || presentation::checked_owner(p, representation) != Some(owner) { return false; } let id = match target { @@ -185,7 +219,7 @@ impl State { _ => return false, }; let Some(usage) = self - .diagram_usages(p, owner, id) + .representation_usages(p, representation, id) .into_iter() .find(|u| &u.target == target) else { @@ -193,11 +227,29 @@ impl State { }; // Preserve an existing mounted viewport across enter's owner reset. let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.)); - self.enter(document, owner); + if !viewport.width.is_finite() + || !viewport.height.is_finite() + || viewport.width <= 0. + || viewport.height <= 0. + { + return false; + } + let (zoom, pan) = if matches!(target, Target::Whole) { + let Some(camera) = super::camera::fit(usage.bounds, viewport) else { + return false; + }; + (None, camera.pan) + } else { + let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport); + if !zoom.is_finite() || !pan.x.is_finite() || !pan.y.is_finite() { + return false; + } + (Some(zoom), pan) + }; + self.enter_representation(document, owner, representation); self.presentation.viewport = Some(viewport); self.canvas_tool = Some((owner, CanvasTool::Select)); - let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport); - self.presentation.zoom(Some(zoom)); + self.presentation.zoom(zoom); self.presentation.pan = pan; true } diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 6a9001f5..5d5ee058 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -13,13 +13,14 @@ pub enum Shelf { #[derive(Clone, Default)] pub(super) struct Presentation { pub(super) identity: Option<(Id, Id)>, + pub(super) representation: Option, + pub(super) visit: Id, pub(super) viewport: Option, pub(super) shelf: Option, pub(super) pan: iced::Vector, pub(super) zoom: Option, pub(super) fit: bool, } -const MIN_SCALE: f32 = 0.05; impl Presentation { pub(super) fn viewport(&mut self, size: Size) -> bool { if !size.width.is_finite() @@ -34,29 +35,26 @@ impl Presentation { true } pub(super) fn zoom(&mut self, scale: Option) -> bool { - if scale.is_some_and(|value| !value.is_finite() || value <= 0.) { - return false; - } - let next = scale.map(|value| value.clamp(MIN_SCALE, 3.)); - let changed = self.zoom != next || self.fit != scale.is_none(); - self.zoom = next; - self.fit = scale.is_none(); - changed + camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale) } - fn fit_scale(&self, scene: Size) -> Option { - let viewport = self.viewport?; - // Fit uses the actual mounted viewport with a small visual gutter. - Some( - ((viewport.width - 16.).max(1.) / scene.width.max(1.)) - .min((viewport.height - 16.).max(1.) / scene.height.max(1.)), - ) + pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera { + camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds) } - pub(super) fn scale(&self, scene: Size) -> f32 { - if self.fit { - self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.) - } else { - self.zoom.unwrap_or(1.) - } + // Legacy tests call size-only scale; actual display always passes full bounds. + #[cfg(test)] + pub(super) fn scale(&self, size: Size) -> f32 { + self.camera(Rectangle::new(Point::ORIGIN, size)).scale + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ArrangementChoice { + representation: Representation, + label: String, +} +impl std::fmt::Display for ArrangementChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.label) } } @@ -68,43 +66,136 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a .draft .as_ref() .is_some_and(|draft| draft.owner == owner); - let context = app.scenario_context().expect("visible scenario context"); + let Some(context) = app.scenario_context() else { + return column![ + out, + text("Scenario view is unavailable."), + button("Open selected source").on_press(Message::SourceForSelection) + ] + .spacing(8) + .into(); + }; let tool = state.canvas_tool(owner); let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(); - let mut scene = Scene::sequence(p, owner, app.selected); - let scale = presentation.scale(scene.size); - scene.scale = scale; - scene.camera_pan = presentation.pan; - scene.interaction = Some((context, tool, clean)); - scene.pending_placement = state.pending_placement(owner); + // Resolve once per accepted view. An active spacing session owns its exact + // prepared geometry; borrowing it also retains the canvas input state. + let preview = state.spacing_paint(context, app.camera_epoch); + let shown = preview + .is_none() + .then(|| presentation::present(p, context.representation, app.selected)); + let capabilities = shown.as_ref().map_or( + presentation::PresentationCapabilities { + geometry_navigation: true, + coordinate_input: true, + }, + ScenarioPresentation::capabilities, + ); + let coordinate_input = clean && capabilities.coordinate_input; + let notice = if let Some(paint) = &preview { + display_notice_parts( + p, + context.representation, + &presentation::PresentationStatus::Ready, + paint.original_diagnostics, + paint.original_latent, + ) + } else { + display_notice(p, shown.as_ref().expect("normal presentation")) + }; + let camera_state = preview + .as_ref() + .map(|paint| camera::Camera { + scale: paint.scene.scale, + pan: paint.scene.camera_pan, + }) + .or_else(|| { + shown + .as_ref() + .and_then(ScenarioPresentation::bounds) + .map(|bounds| presentation.camera(bounds)) + }); + let scale = camera_state.map_or(1., |camera| camera.scale); let scope = super::super::canvas_workspace::Scope { document: context.document, owner, revision: context.revision, epoch: app.camera_epoch, + scenario_view: Some((context.representation, context.visit)), }; - let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app); + let keyboard = clean + && capabilities.geometry_navigation + && super::super::canvas_workspace::keyboard_enabled(app); let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e)); - let canvas = container( - sensor( - super::super::canvas_workspace::Viewport::new( - canvas::Canvas::new(scene).width(Fill).height(Fill), - scope, - scale, - keyboard, - camera, - ) - .pan_background(tool == CanvasTool::Select), + let raw_canvas: Option> = if let Some(paint) = &preview { + Some( + canvas::Canvas::new(paint.scene) + .width(Fill) + .height(Fill) + .into(), ) - .key((context.document, owner)) - .on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))) - .on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))), - ) - .id("scenario-viewport") - .width(Fill) - .height(Fill) - .style(style::card) - .into(); + } else if let Some(mut scene) = shown.and_then(ScenarioPresentation::into_scene) { + let camera_state = camera_state.expect("displayed Scene has complete bounds"); + scene.scale = camera_state.scale; + scene.camera_pan = camera_state.pan; + scene.interaction = Some((context, tool, clean)); + scene.spacing_stamp = (coordinate_input + && !app.source_rename.open() + && keyboard + && matches!(context.representation, Representation::Saved { .. })) + .then_some(spacing::Stamp { + context, + camera_epoch: app.camera_epoch, + }); + scene.coordinate_input = capabilities.coordinate_input; + scene.pending_placement = coordinate_input + .then(|| state.pending_placement(owner)) + .flatten(); + Some(canvas::Canvas::new(scene).width(Fill).height(Fill).into()) + } else { + None + }; + let canvas: Element<'a, Message> = if let Some(raw_canvas) = raw_canvas { + // Keep this Stack in both branches: replacing the Canvas with a wrapper + // on Begin would discard its pointer capture before Move/Finish. + let mut layers = iced::widget::stack![raw_canvas]; + if let Some(paint) = &preview { + let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics); + layers = layers.push( + container( + container(text(feedback).size(12)) + .padding(6) + .width(Fill) + .style(style::card), + ) + .width(Fill) + .align_bottom(Fill) + .padding(8), + ); + } + container( + sensor( + super::super::canvas_workspace::Viewport::new( + layers, scope, scale, keyboard, camera, + ) + .pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input), + ) + .key(( + context.document, + format!("{:?}", context.representation), + context.visit, + )) + .on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))) + .on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))), + ) + .id("scenario-viewport") + .width(Fill) + .height(Fill) + .style(style::card) + .into() + } else { + container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view.")) + .id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into() + }; let mut navigation = row![out.width(Fill)].spacing(6); if state.can_back(p, context) { navigation = @@ -112,20 +203,72 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))), )); } - let header = column![ - navigation, - super::super::canvas_workspace::controls( + let mut header = column![navigation, notice].spacing(4); + let saved_views = p + .diagrams + .values() + .filter(|diagram| { + presentation::checked_owner( + p, + Representation::Saved { + diagram: diagram.id, + }, + ) == Some(owner) + }) + .map(|diagram| ArrangementChoice { + representation: Representation::Saved { + diagram: diagram.id, + }, + label: short(&diagram.name, 64), + }) + .collect::>(); + if !saved_views.is_empty() { + let mut choices = vec![ArrangementChoice { + representation: Representation::Derived { owner }, + label: "Automatic arrangement".into(), + }]; + choices.extend(saved_views); + let selected = choices + .iter() + .find(|choice| choice.representation == context.representation) + .cloned(); + header = header.push( + container( + pick_list(choices, selected, move |choice: ArrangementChoice| { + Message::ScenarioCanvas(CanvasEvent::Representation( + context, + choice.representation, + )) + }) + .placeholder("Choose arrangement") + .text_size(12) + .padding(5) + .width(Fill), + ) + .width(Fill) + .id("scenario-arrangement-picker"), + ); + } + if capabilities.geometry_navigation { + header = header.push(super::super::canvas_workspace::controls( scope, keyboard, scale, Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)), move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)), - camera - ) - ] - .spacing(4); + camera, + )); + } let entries = [ - ("S", "Select / reorder occurrences", CanvasTool::Select), + ( + "S", + if matches!(context.representation, Representation::Saved { .. }) { + "Select / move messages and lifelines" + } else { + "Select / reorder occurrences" + }, + CanvasTool::Select, + ), ( "L", "Lifeline: click to choose represented work or participant", @@ -192,16 +335,21 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a glyph, label, choice == tool, - clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose( - context, choice, - ))), + (clean && (choice == CanvasTool::Select || capabilities.coordinate_input)) + .then_some(Message::ScenarioCanvas(CanvasEvent::Choose( + context, choice, + ))), ) }), ); let mut details = column![ text(format!("Selected: {}", p.named(app.selected))).size(12), - button(text("Properties").size(12)) - .on_press_maybe(clean.then_some(Message::EditProperties(app.selected))) + container(button(text("Properties").size(12)).on_press_maybe( + (clean && properties_target(p, context, app.selected)).then_some( + Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)), + ), + )) + .id("scenario-properties") ] .spacing(6); if referenced_scenario(p, owner, app.selected).is_some() { @@ -212,6 +360,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a ))), )); } + details = details.push(text("Scenario order").size(13)); + if matches!(context.representation, Representation::Saved { .. }) { + details = details.push(text("↑ and ↓ change the shared Scenario order. Saved message positions are retained; automatically placed items may move.").size(11)); + } let details = details_pane( context, state.scroll_restore(context, ScrollArea::Page), @@ -541,6 +693,153 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a super::super::canvas_workspace::host(header.into(), palette, canvas, editor) } +fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> { + display_notice_parts( + p, + shown.requested(), + shown.status(), + shown.diagnostics(), + shown.latent_messages(), + ) +} + +fn display_notice_parts<'a>( + p: &Project, + requested: Representation, + status: &presentation::PresentationStatus, + diagnostics: &[super::super::scenario_layout::Diagnostic], + latent_messages: &[Id], +) -> Element<'a, Message> { + use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus}; + let title = match requested { + Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else( + || "Unavailable saved Scenario view".into(), + |view| format!("Saved view · {}", view.name), + ), + Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)), + }; + let mut notice = column![text(title).size(14)].spacing(4); + match status { + PresentationStatus::Ready => { + for diagnostic in diagnostics.iter().take(3) { + let explanation = match diagnostic { + Diagnostic::SourceOrderCrossing { before, after, .. } => format!( + "Saved spacing places '{}' below '{}'. The Scenario order is unchanged.", + p.named(*before), + p.named(*after) + ), + Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!( + "Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.", + p.named(*frame), + p.named(*following) + ), + }; + notice = notice.push(text(explanation).size(12)); + } + if diagnostics.len() > 3 { + notice = notice.push( + text(format!( + "{} additional spacing notices are retained.", + diagnostics.len() - 3 + )) + .size(12), + ); + } + } + PresentationStatus::Fallback { requested_issues } => { + notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12)); + if let Some(issue) = requested_issues.first() { + notice = notice.push(text(geometry_problem(p, issue)).size(12)); + } + notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12)); + } + PresentationStatus::Unavailable { .. } => { + notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12)); + } + } + if !latent_messages.is_empty() { + notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", latent_messages.len())).size(12)); + } + notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection)); + container(notice) + .id("scenario-display-notice") + .padding(6) + .width(Fill) + .into() +} + +fn spacing_feedback( + p: &Project, + refusal: Option<&spacing::Refusal>, + diagnostics: &[super::super::scenario_layout::Diagnostic], +) -> String { + if let Some(refusal) = refusal { + let reason = match refusal { + spacing::Refusal::Geometry(issues) => issues + .first() + .map(|issue| geometry_problem(p, issue)) + .unwrap_or_else(|| "This position cannot be displayed.".into()), + spacing::Refusal::Command(error) => error.clone(), + _ => "This position is unavailable in the current view.".into(), + }; + return format!( + "Position unavailable · {reason} Move back to a supported position, or release to cancel." + ); + } + let explanation = match diagnostics.first() { + Some(super::super::scenario_layout::Diagnostic::SourceOrderCrossing { + before, + after, + .. + }) => format!( + " '{}' is below '{}'; Scenario order stays unchanged.", + p.named(*before), + p.named(*after) + ), + Some(super::super::scenario_layout::Diagnostic::FrameExtendsPastFollowingAnchor { + frame, + following, + }) => format!(" '{}' overlaps '{}'.", p.named(*frame), p.named(*following)), + None => " Scenario order stays unchanged.".into(), + }; + format!("Spacing preview · Release to finish; Escape cancels.{explanation}") +} + +fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String { + use super::super::scenario_layout::GeometryIssue; + match issue { + GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!( + "The position of '{}' conflicts with the execution of '{}'.", + p.named(*event), + p.named(*call) + ), + GeometryIssue::CoincidentDistinctEndpoints(message) => format!( + "The distinct endpoints of '{}' have the same horizontal position.", + p.named(*message) + ), + GeometryIssue::TypedActivationOutsideLifetime { call, .. } + | GeometryIssue::TypedActivationOutsideScope { call, .. } => format!( + "The position of '{}' falls outside its supported execution interval.", + p.named(*call) + ), + GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!( + "The saved spacing would move the fixed header of '{}'.", + p.named(*frame) + ), + GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!( + "The spacing of '{}' extends outside its enclosing frame.", + p.named(*operand) + ), + GeometryIssue::LifecycleGeometryNotSupported { .. } + | GeometryIssue::LifecycleAnchorOutsideRenderScope { .. } + | GeometryIssue::LifetimeInversionNotSupported { .. } => { + "The saved spacing cannot preserve the participant lifetime geometry.".into() + } + _ => "These saved positions are outside the geometry currently supported by the canvas." + .into(), + } +} + fn sequence_action<'a>( title: &str, message: Message, @@ -597,6 +896,12 @@ fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>( #[cfg(all(test, debug_assertions))] mod tests; +#[cfg(all(test, debug_assertions))] +mod spacing_tests; + +#[cfg(all(test, debug_assertions))] +mod details_tests; + fn sequence_rows<'a>( p: &'a Project, context: Context, diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs new file mode 100644 index 00000000..832d84ff --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/workspace/details_tests.rs @@ -0,0 +1,437 @@ +//! Mounted Scenario Details → inspector regression and retained-intent guards. +use super::*; +use crate::behavior::canvas_workspace::tests::Mounted; +use crate::behavior::scenario_layout::resolve; +use iced::advanced::widget; +use iced::{Vector, keyboard}; +use std::sync::Arc; + +const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia"); + +fn id(app: &App, alias: &str) -> Id { + app.project() + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} +fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) { + let context = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(event(context))); +} +fn setup() -> (App, Id, Id) { + let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap()); + let owner = id(&app, "incident_response"); + let diagram = id(&app, "incident_readable"); + let message = id(&app, "initial_report"); + let _ = app.update(Message::Select(owner)); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: diagram, + label: "A".into(), + })); + send(&mut app, |c| CanvasEvent::Select(c, message)); + app.window_size = Size::new(1440., 1000.); + (app, diagram, message) +} +fn open_details(app: &mut App) { + if app.behavior.scenario.presentation.shelf.is_none() { + send(app, |c| CanvasEvent::Shelf(c, Shelf::Selection)); + } +} +fn stored_camera(app: &App) -> (Vector, Option, bool) { + let p = &app.behavior.scenario.presentation; + (p.pan, p.zoom, p.fit) +} + +#[tokio::test] +async fn saved_details_opens_real_editable_inspector_and_returns_to_same_view() { + let (mut app, diagram, message) = setup(); + let _ = app.update(Message::InspectorTab( + crate::navigation::InspectorTab::Connections, + )); + let mut mounted = Mounted::new(&mut app).await; + open_details(&mut app); + mounted.settle(&mut app).await; + let before = mounted.geometry(&app); + assert!(before.scrolls.contains_key(&widget::Id::new("inspector"))); + let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")]; + let button = before.bounds("scenario-properties").center() - offset; + assert!( + details.contains(button), + "Properties must be actually visible" + ); + let context = app.scenario_context().unwrap(); + let camera = stored_camera(&app); + let original = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let messages = mounted.click(&mut app, button).await; + assert!(messages.iter().any(|event| matches!(event, + Message::ScenarioCanvas(CanvasEvent::Properties(c, selected)) + if *c == context && *selected == message))); + mounted.settle(&mut app).await; + assert!(app.behavior.scenario.presentation.shelf.is_none()); + assert_eq!( + app.inspector_tab, + crate::navigation::InspectorTab::Properties + ); + assert_eq!(app.selected, message); + assert_eq!(app.active_diagram, Some(diagram)); + assert_eq!(app.page, crate::app::Page::Behavior); + let current = app.scenario_context().unwrap(); + assert_eq!(current.representation, context.representation); + assert_eq!(current.visit, context.visit); + assert_eq!( + stored_camera(&app), + camera, + "restoring panels retains camera intent" + ); + assert!(Arc::ptr_eq(&original, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); + + let shown = mounted.geometry(&app); + assert!( + !shown + .scrolls + .contains_key(&widget::Id::new("scenario-details")) + ); + let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")]; + assert!(inspector.width > 250. && inspector.height > 500.); + // Production Properties places Name before the other scalar text inputs. + // Account for the retained inspector scroll, rather than clicking content coordinates. + let input = shown + .text_inputs + .iter() + .map(|bounds| bounds.center() - offset) + .filter(|point| inspector.contains(*point)) + .min_by(|a, b| a.y.total_cmp(&b.y)) + .expect("visible editable property"); + mounted.click(&mut app, input).await; + let typed = mounted + .batch( + &mut app, + &[iced::Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character("x".into()), + modified_key: keyboard::Key::Character("x".into()), + physical_key: keyboard::key::Physical::Unidentified( + keyboard::key::NativeCode::Unidentified, + ), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: Some("x".into()), + repeat: false, + })], + mouse::Cursor::Available(input), + ) + .await; + assert!( + typed + .iter() + .any(|message| matches!(message, Message::Field(path, _) if path == "/name")), + "real Name editor must publish its edit: {typed:?}" + ); + assert!(app.draft_dirty()); + assert!( + Arc::ptr_eq(&original, &app.document.snapshot()), + "typing is only a draft" + ); + let _ = app.update(Message::RevertProperties); + assert!(!app.draft_dirty()); + mounted.settle(&mut app).await; + let returned = mounted.geometry(&app); + assert!(returned.scrolls.contains_key(&widget::Id::new("inspector"))); + let viewport = returned.bounds("scenario-viewport"); + assert_eq!(app.selected, message); + assert_eq!(app.scenario_context().unwrap().visit, context.visit); + assert_eq!( + app.scenario_context().unwrap().representation, + context.representation + ); + assert_eq!(stored_camera(&app), camera); + let geometry = resolve(app.project(), context.representation, message).unwrap(); + let line = geometry + .scene + .lines + .iter() + .find(|line| line.id == message) + .unwrap(); + let effective = app.behavior.scenario.presentation.camera(geometry.bounds); + let point = viewport.position() + + effective.pan + + Vector::new(line.points[0].x, line.points[0].y) * effective.scale; + assert!( + viewport.contains(point), + "the same selected message remains in fitted view" + ); + assert!(Arc::ptr_eq(&original, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn details_properties_refuses_pending_property_source_and_behavior_drafts_without_apply() { + for kind in 0..3 { + let (mut app, _, message) = setup(); + open_details(&mut app); + match kind { + 0 => { + let _ = app.update(Message::Field( + "/name".into(), + "Retained property draft".into(), + )); + assert!(app.draft_dirty()); + } + 1 => { + let _ = app.update(Message::SourceEdit( + iced::widget::text_editor::Action::Edit( + iced::widget::text_editor::Edit::Insert('x'), + ), + )); + assert!(app.source_dirty()); + } + _ => { + let owner = app.scenario_context().unwrap().scenario; + let _ = app.update(Message::Behavior(super::super::super::Event::Scenario( + Event::Start(owner, Tool::Lifeline), + ))); + assert!(app.behavior.has_pending()); + } + } + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let property = app.draft.as_ref().unwrap().materialize(); + let source = app.source_editor.text(); + let behavior = app.behavior.recovery_snapshot(); + let camera = app.behavior.scenario.camera_boundary(); + let context = app.scenario_context().unwrap(); + let epochs = (app.canvas_epoch, app.camera_epoch); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties( + context, message, + ))); + assert!(app.behavior.scenario.camera_boundary() == camera); + assert_eq!(app.scenario_context(), Some(context)); + assert_eq!((app.canvas_epoch, app.camera_epoch), epochs); + assert_eq!(app.draft.as_ref().unwrap().materialize(), property); + assert_eq!(app.source_editor.text(), source); + assert_eq!(app.behavior.recovery_snapshot(), behavior); + assert_eq!(app.selected, message); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[tokio::test] +async fn details_opens_current_saved_view_properties_after_following_diagram_picker() { + let (mut app, a, _) = setup(); + let b = id(&app, "incident_wide"); + // Selecting the current view record makes the real picker follow that + // record selection to B. Do not seed selected=B or its property draft. + let _ = app.update(Message::Select(a)); + assert_eq!(app.selected, a); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: b, + label: "B".into(), + })); + assert_eq!(app.selected, b); + assert_eq!(app.draft.as_ref().unwrap().original, b); + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { diagram: b } + ); + let _ = app.update(Message::InspectorTab( + crate::navigation::InspectorTab::Connections, + )); + let mut mounted = Mounted::new(&mut app).await; + open_details(&mut app); + mounted.settle(&mut app).await; + let context = app.scenario_context().unwrap(); + let camera = stored_camera(&app); + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let before = mounted.geometry(&app); + assert!(before.scrolls.contains_key(&widget::Id::new("inspector"))); + let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")]; + let point = before.bounds("scenario-properties").center() - offset; + assert!(details.contains(point)); + let events = mounted.click(&mut app, point).await; + assert!(events.iter().any(|event| matches!(event, + Message::ScenarioCanvas(CanvasEvent::Properties(c, selected)) + if *c == context && *selected == b))); + mounted.settle(&mut app).await; + let shown = mounted.geometry(&app); + assert!( + !shown + .scrolls + .contains_key(&widget::Id::new("scenario-details")) + ); + let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")]; + assert!( + shown + .text_inputs + .iter() + .any(|bounds| inspector.contains(bounds.center() - offset)), + "current Saved diagram has a visible editable inspector" + ); + assert_eq!( + app.inspector_tab, + crate::navigation::InspectorTab::Properties + ); + assert_eq!(app.selected, b); + assert_eq!(app.draft.as_ref().unwrap().original, b); + assert_eq!(app.active_diagram, Some(b)); + assert_eq!(app.page, crate::app::Page::Behavior); + assert_eq!( + app.scenario_context().unwrap().representation, + context.representation + ); + assert_eq!(app.scenario_context().unwrap().visit, context.visit); + assert_eq!(stored_camera(&app), camera); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn details_properties_refuses_another_saved_view_even_with_the_same_scenario_owner() { + let (mut app, a, _) = setup(); + let b = id(&app, "incident_wide"); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: b, + label: "B".into(), + })); + let _ = app.update(Message::Select(a)); + assert_eq!(app.selected, a); + open_details(&mut app); + let context = app.scenario_context().unwrap(); + assert_eq!(context.representation, Representation::Saved { diagram: b }); + assert_eq!( + presentation::checked_owner(app.project(), Representation::Saved { diagram: a }), + Some(context.scenario) + ); + assert!(!properties_target(app.project(), context, a)); + let camera = app.behavior.scenario.camera_boundary(); + let epochs = (app.canvas_epoch, app.camera_epoch); + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(context, a))); + assert!(app.behavior.scenario.camera_boundary() == camera); + assert_eq!((app.canvas_epoch, app.camera_epoch), epochs); + assert_eq!(app.scenario_context(), Some(context)); + assert_eq!(app.selected, a); + assert_eq!(app.draft.as_ref().unwrap().original, a); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn details_properties_rejects_retired_view_selection_and_epoch_without_changing_panels() { + for kind in 0..3 { + let (mut app, diagram, message) = setup(); + open_details(&mut app); + let captured = app.scenario_context().unwrap(); + match kind { + 0 => { + let other = id(&app, "incident_wide"); + for view in [other, diagram] { + let _ = app.update(Message::Diagram(crate::app::Choice { + id: view, + label: "view".into(), + })); + } + open_details(&mut app); + assert_ne!(app.scenario_context().unwrap().visit, captured.visit); + } + 1 => { + let other = id(&app, "updated_report"); + send(&mut app, |c| CanvasEvent::Select(c, other)); + } + _ => { + send(&mut app, |c| CanvasEvent::Zoom(c, Some(0.5))); + } + } + let camera = app.behavior.scenario.camera_boundary(); + let context = app.scenario_context(); + let selected = app.selected; + let tab = app.inspector_tab; + let epochs = (app.canvas_epoch, app.camera_epoch); + let snapshot = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties( + captured, message, + ))); + assert!(app.behavior.scenario.camera_boundary() == camera); + assert_eq!(app.scenario_context(), context); + assert_eq!(app.selected, selected); + assert_eq!(app.inspector_tab, tab); + assert_eq!((app.canvas_epoch, app.camera_epoch), epochs); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + } +} + +#[test] +fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish() { + use super::super::spacing::{Event as Spacing, Target, controller::tests::packet}; + let (mut app, _, message) = setup(); + send(&mut app, |c| { + CanvasEvent::Viewport(c, Size::new(900., 600.)) + }); + open_details(&mut app); + let begin = packet( + app.project(), + &app.behavior.scenario, + app.scenario_context().unwrap(), + app.camera_epoch, + Target::Message(message), + ); + let Spacing::Begin { press_screen, .. } = begin else { + panic!() + }; + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin))); + assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token())); + let snapshot = app.document.snapshot(); + let end = press_screen + Vector::new(0., 12.); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing( + Spacing::Move { + stamp: begin.stamp(), + token: begin.token(), + screen: end, + }, + ))); + let paint = app + .behavior + .scenario + .spacing_paint(begin.stamp().context, begin.stamp().camera_epoch) + .unwrap(); + assert!(paint.refusal.is_none()); + let line = paint + .scene + .lines + .iter() + .find(|line| line.id == message) + .unwrap(); + assert!( + line.points[0].y > 160., + "a real proposed scene moved the first message" + ); + assert!( + Arc::ptr_eq(&snapshot, &app.document.snapshot()), + "preview is not authored" + ); + let camera = stored_camera(&app); + send(&mut app, |c| CanvasEvent::Properties(c, message)); + assert!(app.behavior.scenario.spacing_token().is_none()); + assert_eq!(stored_camera(&app), camera); + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing( + Spacing::Finish { + stamp: begin.stamp(), + token: begin.token(), + screen: end, + }, + ))); + assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot())); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/spacing_tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/spacing_tests.rs new file mode 100644 index 00000000..5f3bd640 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/workspace/spacing_tests.rs @@ -0,0 +1,438 @@ +//! Real mounted widgets dispatch the gesture; expectations name authored fields +//! and actual displayed anchors independently of controller command results. +use super::*; +use crate::behavior::canvas_workspace::tests::{Mounted, key}; +use crate::behavior::scenario_layout::{ResolvedScenarioPlan, resolve}; +use crate::document::Document; +use iced::Vector; +use std::sync::Arc; + +const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia"); + +fn id(app: &App, alias: &str) -> Id { + app.project() + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} + +async fn setup(fit: bool) -> (App, Mounted, Id) { + let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap()); + let owner = id(&app, "incident_response"); + let diagram = id(&app, "incident_readable"); + let _ = app.update(Message::Select(owner)); + let _ = app.update(Message::Diagram(crate::app::Choice { + id: diagram, + label: "A - Incident review readable".into(), + })); + app.window_size = Size::new(1440., 900.); + let mut mounted = Mounted::new(&mut app).await; + if !fit { + let context = app.scenario_context().unwrap(); + let task = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom( + context, + Some(0.5), + ))); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + } + assert_eq!( + app.scenario_context().unwrap().representation, + Representation::Saved { diagram } + ); + assert!(!app.document.can_undo() && !app.document.can_redo()); + (app, mounted, diagram) +} + +fn resolved(app: &App) -> ResolvedScenarioPlan { + resolve( + app.project(), + app.scenario_context().unwrap().representation, + app.selected, + ) + .unwrap() +} + +fn stored_camera(app: &App) -> (Vector, Option, bool) { + let p = &app.behavior.scenario.presentation; + (p.pan, p.zoom, p.fit) +} + +fn screen(mounted: &mut Mounted, app: &App, world: Point) -> Point { + let bounds = mounted.geometry(app).bounds("scenario-viewport"); + let geometry = resolved(app); + let camera = app.behavior.scenario.presentation.camera(geometry.bounds); + let point = bounds.position() + camera.pan + Vector::new(world.x, world.y) * camera.scale; + assert!( + bounds.contains(point), + "actual visible world {world:?}: {point:?} in {bounds:?}" + ); + point +} + +fn mouse_event(event: mouse::Event) -> iced::Event { + iced::Event::Mouse(event) +} + +async fn press(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec { + mounted + .batch( + app, + &[ + mouse_event(mouse::Event::CursorMoved { position: point }), + mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)), + ], + mouse::Cursor::Available(point), + ) + .await +} + +async fn move_to(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec { + mounted + .batch( + app, + &[mouse_event(mouse::Event::CursorMoved { position: point })], + mouse::Cursor::Available(point), + ) + .await +} + +async fn release(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec { + mounted + .batch( + app, + &[mouse_event(mouse::Event::ButtonReleased( + mouse::Button::Left, + ))], + mouse::Cursor::Available(point), + ) + .await +} + +fn set_expected_message(project: &mut oa_core::Project, diagram: Id, occurrence: Id, y: f32) { + let Some(oa_core::DiagramLayout::Scenario(layout)) = + &mut project.diagrams.get_mut(&diagram).unwrap().layout + else { + panic!() + }; + layout + .messages + .iter_mut() + .find(|m| m.address.occurrence == occurrence) + .unwrap() + .send_y = y; +} + +fn set_expected_lane(project: &mut oa_core::Project, diagram: Id, lifeline: Id, x: f32) { + let Some(oa_core::DiagramLayout::Scenario(layout)) = + &mut project.diagrams.get_mut(&diagram).unwrap().layout + else { + panic!() + }; + layout + .lanes + .iter_mut() + .find(|lane| lane.lifeline == lifeline) + .unwrap() + .x = x; +} + +fn assert_reopens_exactly(app: &App) { + let reopened = Document::from_bundle( + app.document.bundle().clone(), + oa_core::source_project::ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(reopened.project(), app.project()); + assert_eq!(reopened.bundle(), app.document.bundle()); +} + +#[tokio::test] +async fn saved_message_same_batch_pointer_events_commit_once_and_one_undo_redo_restores_exact_source() + { + let (mut app, mut mounted, diagram) = setup(false).await; + let target = id(&app, "updated_report"); + let before = app.document.snapshot(); + let revision = app.document.revision(); + let projected = screen(&mut mounted, &app, Point::new(400., 252.)); + // This fixture asks for exactly +20 world units. A fractional start near a + // screen-coordinate exponent boundary need not retain an exact +10 delta. + let start = Point::new(projected.x.round(), projected.y.round()); + let mut geometry = resolved(&app); + let camera = app.behavior.scenario.presentation.camera(geometry.bounds); + assert_eq!(camera.scale, 0.5); + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let world = Point::new( + (start.x - bounds.x - camera.pan.x) / camera.scale, + (start.y - bounds.y - camera.pan.y) / camera.scale, + ); + geometry.scene.scale = camera.scale; + geometry.scene.camera_pan = camera.pan; + let hit = geometry.scene.hit_item(world).unwrap(); + assert_eq!(hit.id, target); + assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody); + let end = start + Vector::new(0., 10.); // .5 display scale: +20 authored y. + assert_eq!(end.y - start.y, 10.); + assert!(bounds.contains(start) && bounds.contains(end)); + let messages = mounted + .batch( + &mut app, + &[ + mouse_event(mouse::Event::CursorMoved { position: start }), + mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)), + mouse_event(mouse::Event::CursorMoved { position: end }), + mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)), + ], + mouse::Cursor::Available(end), + ) + .await; + assert_eq!( + messages + .iter() + .filter(|m| matches!( + m, + Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. })) + )) + .count(), + 1 + ); + assert_eq!( + messages + .iter() + .filter(|m| matches!( + m, + Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. })) + )) + .count(), + 1 + ); + let mut expected = before.decoded.project.clone(); + set_expected_message(&mut expected, diagram, target, 272.); + assert_eq!( + app.project(), + &expected, + "Only Saved A's addressed y changes; steps, other occurrences and B/C are exact" + ); + assert_eq!(app.document.revision(), revision + 1); + assert_eq!(app.selected, target); + assert!(app.behavior.scenario.spacing_token().is_none()); + assert_reopens_exactly(&app); + let after = app.document.snapshot(); + let task = app.update(Message::Undo); + mounted.perform(&mut app, task).await; + assert_eq!(app.project(), &before.decoded.project); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo() && app.document.can_redo()); + let task = app.update(Message::Redo); + mounted.perform(&mut app, task).await; + assert_eq!(app.project(), &after.decoded.project); + assert_eq!(app.document.bundle(), &after.bundle); +} + +#[tokio::test] +async fn saved_header_preview_survives_frames_and_freezes_fit_while_actual_bounds_expand() { + let (mut app, mut mounted, diagram) = setup(true).await; + let assessor = id(&app, "assessor"); + let before = app.document.snapshot(); + let original = resolved(&app); + let original_camera = app.behavior.scenario.presentation.camera(original.bounds); + let stored = stored_camera(&app); + assert!(stored.2, "test must exercise original Fit"); + let start = screen(&mut mounted, &app, Point::new(640., 45.)); + press(&mut mounted, &mut app, start).await; + let token = app + .behavior + .scenario + .spacing_token() + .expect("actual header press begins"); + for x in [670., 700.] { + let end = start + Vector::new((x - 640.) * original_camera.scale, 0.); + move_to(&mut mounted, &mut app, end).await; + mounted.settle(&mut app).await; // Actual borrowed Scene/Stack tree is rebuilt. + assert_eq!(app.behavior.scenario.spacing_token(), Some(token)); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(stored_camera(&app), stored); + let paint = app + .behavior + .scenario + .spacing_paint(app.scenario_context().unwrap(), app.camera_epoch) + .unwrap(); + assert!(paint.refusal.is_none()); + assert!((paint.scene.render_lanes[&assessor] - x).abs() < 0.001); + assert!(paint.bounds.width > original.bounds.width); + assert_eq!(paint.scene.scale, original_camera.scale); + assert_eq!(paint.scene.camera_pan, original_camera.pan); + let header = paint.scene.boxes.iter().find(|b| b.id == assessor).unwrap(); + let displayed = Vector::new(header.rect.center().x, header.rect.center().y) + * paint.scene.scale + + paint.scene.camera_pan; + let baseline = Vector::new(640., 50.) * original_camera.scale + original_camera.pan; + assert!((displayed.x - baseline.x - (x - 640.) * original_camera.scale).abs() < 0.001); + assert_eq!(displayed.y, baseline.y); + } + let end = start + Vector::new(60. * original_camera.scale, 0.); + release(&mut mounted, &mut app, end).await; + let final_geometry = resolved(&app); + let actual_x = final_geometry.scene.render_lanes[&assessor]; + assert!((actual_x - 700.).abs() < 0.001); + let mut expected = before.decoded.project.clone(); + set_expected_lane(&mut expected, diagram, assessor, actual_x); + assert_eq!(app.project(), &expected); + let final_camera = app + .behavior + .scenario + .presentation + .camera(final_geometry.bounds); + assert_eq!( + final_camera, original_camera, + "commit must not refit underneath pointer" + ); + assert!(!app.behavior.scenario.presentation.fit); + assert_eq!(app.selected, assessor); + assert_reopens_exactly(&app); +} + +#[tokio::test] +async fn saved_body_and_header_clicks_select_without_materializing_spacing() { + let (mut app, mut mounted, _) = setup(false).await; + let before = app.document.snapshot(); + let original_camera = stored_camera(&app); + for (alias, world) in [ + ("updated_report", Point::new(400., 252.)), + ("assessor", Point::new(640., 45.)), + ] { + let target = id(&app, alias); + let point = screen(&mut mounted, &app, world); + mounted.click(&mut app, point).await; + assert_eq!(app.selected, target); + assert!(app.behavior.scenario.spacing_token().is_none()); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(stored_camera(&app), original_camera); + } + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn saved_valid_preview_then_refused_self_call_never_commits_last_valid_candidate() { + let (mut app, mut mounted, _) = setup(false).await; + let target = id(&app, "assess_call"); + let before = app.document.snapshot(); + let camera = stored_camera(&app); + let start = screen(&mut mounted, &app, Point::new(680., 344.)); + press(&mut mounted, &mut app, start).await; + let valid = start + Vector::new(0., -10.); // send324, receive348, reply436 unchanged. + move_to(&mut mounted, &mut app, valid).await; + mounted.settle(&mut app).await; + let paint = app + .behavior + .scenario + .spacing_paint(app.scenario_context().unwrap(), app.camera_epoch) + .unwrap(); + assert!(paint.refusal.is_none()); + let line = paint.scene.lines.iter().find(|l| l.id == target).unwrap(); + assert_eq!(line.points[0].y, 324.); + assert_eq!(line.points.last().unwrap().y, 348.); + let refused = start + Vector::new(0., 53.); // send450, receive474 would follow reply436. + move_to(&mut mounted, &mut app, refused).await; + mounted.settle(&mut app).await; + let paint = app + .behavior + .scenario + .spacing_paint(app.scenario_context().unwrap(), app.camera_epoch) + .unwrap(); + assert!(paint.refusal.is_some()); + assert_eq!( + paint + .scene + .lines + .iter() + .find(|l| l.id == target) + .unwrap() + .points[0] + .y, + 344. + ); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + release(&mut mounted, &mut app, refused).await; + assert!(app.behavior.scenario.spacing_token().is_none()); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(stored_camera(&app), camera); + assert!(!app.document.can_undo() && !app.document.can_redo()); +} + +#[tokio::test] +async fn saved_escape_and_outside_pointer_cancel_actual_held_preview() { + for escape in [true, false] { + let (mut app, mut mounted, _) = setup(false).await; + let before = app.document.snapshot(); + let camera = stored_camera(&app); + let start = screen(&mut mounted, &app, Point::new(400., 252.)); + let end = start + Vector::new(0., 10.); + press(&mut mounted, &mut app, start).await; + move_to(&mut mounted, &mut app, end).await; + mounted.settle(&mut app).await; + assert!(app.behavior.scenario.spacing_token().is_some()); + let release_at = if escape { + mounted + .batch( + &mut app, + &[key(iced::keyboard::key::Named::Escape)], + mouse::Cursor::Available(end), + ) + .await; + end + } else { + let bounds = mounted.geometry(&app).bounds("scenario-viewport"); + let outside = Point::new(bounds.x - 5., bounds.y + 100.); + move_to(&mut mounted, &mut app, outside).await; + outside + }; + assert!(app.behavior.scenario.spacing_token().is_none()); + release(&mut mounted, &mut app, release_at).await; + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(stored_camera(&app), camera); + assert!(!app.document.can_undo() && !app.document.can_redo()); + } +} + +#[tokio::test] +async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_camera() { + let (mut app, mut mounted, _) = setup(true).await; + let owner = id(&app, "incident_response"); + app.document + .execute(oa_core::Command::Rename( + owner, + "Previously edited owner".into(), + )) + .unwrap(); + let redo = app.document.snapshot(); + let task = app.update(Message::Undo); + mounted.perform(&mut app, task).await; + mounted.settle(&mut app).await; + assert!(app.document.can_redo()); + let before = app.document.snapshot(); + let camera = stored_camera(&app); + let selected = app.selected; + let revision = app.document.revision(); + let start = screen(&mut mounted, &app, Point::new(400., 252.)); + press(&mut mounted, &mut app, start).await; + move_to(&mut mounted, &mut app, start + Vector::new(0., 12.)).await; + mounted.settle(&mut app).await; + assert!(app.behavior.scenario.spacing_token().is_some()); + move_to(&mut mounted, &mut app, start).await; + mounted.settle(&mut app).await; + release(&mut mounted, &mut app, start).await; + assert!(app.behavior.scenario.spacing_token().is_none()); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.document.revision(), revision); + assert_eq!(stored_camera(&app), camera); + assert_eq!(app.selected, selected); + assert!(app.document.can_redo()); + let task = app.update(Message::Redo); + mounted.perform(&mut app, task).await; + assert_eq!(app.project(), &redo.decoded.project); + assert_eq!(app.document.bundle(), &redo.bundle); +} diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs index aa77d685..14f69205 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs @@ -57,8 +57,28 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu mounted.pan(&mut app, start, start + delta).await; let diff = app.behavior.scenario.presentation.pan - next_pan; assert!(diff.x.hypot(diff.y) < 0.01); - let tool = mounted.geometry(&app).bounds("behavior-tool:A").center(); - mounted.click(&mut app, tool).await; + // Iced operations report palette children in content coordinates. + // The preceding Action focus may have scrolled this retained palette. + let geometry = mounted.geometry(&app); + let scope = canvas_workspace::Scope::current(&app).unwrap(); + let (palette, offset) = geometry.scrolls[&scope.control_id("palette-scroll")]; + let tool = geometry.bounds("behavior-tool:A").center() - offset; + assert!( + palette.contains(tool), + "visible Action at {tool:?}: {palette:?}" + ); + let chosen = mounted.click(&mut app, tool).await; + assert!( + chosen.iter().any(|message| matches!( + canvas_workspace::tests::intent_action(message), + Message::ScenarioCanvas(CanvasEvent::Choose(_, CanvasTool::Add(Tool::Action))) + )), + "Action palette click at scale {scale}, offset {offset:?}: {chosen:?}" + ); + assert_eq!( + app.behavior.scenario.canvas_tool(f.owner), + CanvasTool::Add(Tool::Action) + ); let messages = mounted.click(&mut app, bounds.position() + desired).await; assert!( messages.iter().any( @@ -66,7 +86,7 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu tool: Tool::Action, slot, lifeline: Some(line), .. }) if *slot == expected && *line == f.sender) ), - "scale {scale}: {messages:?}" + "scale {scale}, expected slot {expected:?}: {messages:?}" ); let session = app.behavior.scenario.form_session().unwrap(); let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation; @@ -1348,7 +1368,18 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas let bounds = mounted.geometry(&app).bounds("scenario-viewport"); let point = bounds.position() + iced::Vector::new(95., 90.); let lifetime = app.camera_epoch; - let pan = app.behavior.scenario.presentation.pan; + // Fit stores no pan offset. A real drag starts from the displayed fitted + // camera, then materializes it before applying its ordered screen deltas. + let shown = app + .behavior + .scenario + .display(app.project(), f.owner, app.selected) + .unwrap(); + let before_camera = app + .behavior + .scenario + .presentation + .camera(shown.bounds().unwrap()); let first = point + iced::Vector::new(13., 9.); let last = first + iced::Vector::new(17., -3.); let emitted = mounted @@ -1363,7 +1394,21 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas mouse::Cursor::Available(last), ) .await; - assert_eq!(app.behavior.scenario.presentation.pan, pan + (last - point)); + let expected_pan = before_camera.pan + (last - point); + let actual_pan = app.behavior.scenario.presentation.pan; + let difference = actual_pan - expected_pan; + assert!( + difference.x.hypot(difference.y) < 0.001, + "{boundary}: displayed pan {actual_pan:?} must follow {expected_pan:?}" + ); + assert_eq!( + app.behavior.scenario.presentation.zoom, + Some(before_camera.scale) + ); + assert!( + !app.behavior.scenario.presentation.fit, + "drag materializes the visible Fit camera" + ); assert_eq!( app.camera_epoch, lifetime, "own ordered deltas retain their lifetime" @@ -1768,14 +1813,19 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp assert!(!presentation.viewport(Size::new(484., 204.))); let scale = presentation.scale(graph); assert!(graph.width * scale <= 468. && graph.height * scale <= 188.); - assert!(scale > MIN_SCALE); + assert!(scale > camera::MIN_SCALE); for bad in [f32::NAN, f32::INFINITY, 0., -1.] { assert!(!presentation.zoom(Some(bad))); assert!(!presentation.viewport(Size::new(bad, 100.))); assert!(!presentation.viewport(Size::new(100., bad))); assert_eq!(presentation.scale(graph), scale); } - assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE); + let tall = Rectangle::with_size(Size::new(900., 10000.)); + let camera = presentation.camera(tall); + assert!(camera.scale < 0.05 && camera.scale > 0.); + assert!((camera.scale - 188. / 10000.).abs() < 1e-8); + assert!((tall.height * camera.scale + camera.pan.y - 196.).abs() < 1e-4); + assert!((camera.pan.y - 8.).abs() < 1e-4); assert!(presentation.zoom(Some(1.25))); presentation.viewport(Size::new(200., 100.)); assert_eq!( diff --git a/crates/oa-desktop/src/behavior/scenario_hit_tests.rs b/crates/oa-desktop/src/behavior/scenario_hit_tests.rs new file mode 100644 index 00000000..55eb11de --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_hit_tests.rs @@ -0,0 +1,386 @@ +//! Actual Scene primitives, with independently chosen points and semantic IDs. +//! These preserve the existing label hit band; they do not establish ink bounds +//! or authoring eligibility, and do not dispatch a spacing gesture. +use super::*; +use crate::document::Document; + +struct Fixture { + project: Project, + owner: Id, + sender: Id, + receiver: Id, + call: Id, + reply: Id, + exchange: Id, +} + +fn fixture() -> Fixture { + let project = language::parse(r#"arcadia 7; +project hits "Hit roles" { + oa_activity source "Source" {} + oa_activity target "Target" {} + interaction exchange "Contract" { source @source; target @target; } + oa_scenario conversation "Conversation" { viewpoint OperationalActivity; steps [@call, @reply]; } + oa_lifeline left "Left" { parent @conversation; represented @source; } + oa_lifeline right "Right" { parent @conversation; represented @target; } + oa_occurrence call "Call" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Call; }; }; } + oa_occurrence reply "Reply" { parent @conversation; body { Exchange { exchange @exchange; sender @right; receiver @left; message_kind Reply; reply_to @call; }; }; } +} +"#).unwrap(); + let id = |alias: &str| { + project + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 + }; + Fixture { + owner: id("conversation"), + sender: id("left"), + receiver: id("right"), + call: id("call"), + reply: id("reply"), + exchange: id("exchange"), + project, + } +} + +fn scene(f: &Fixture) -> Scene { + let document = + Document::from_project(f.project.clone(), source_project::ProjectLimits::default()) + .unwrap(); + let before = document.snapshot(); + let result = Scene::sequence_geometry( + document.project(), + f.owner, + f.call, + Some(&BTreeMap::from([(f.sender, 160.), (f.receiver, 400.)])), + None, + ); + assert_eq!(document.project(), &before.decoded.project); + assert_eq!(document.bundle(), &before.bundle); + assert_eq!(document.revision(), 0); + assert!(!document.can_undo() && !document.can_redo() && !document.dirty()); + result +} + +fn hit(scene: &Scene, point: Point, id: Id, role: SceneHitRole) { + assert_eq!(scene.hit_item(point), Some(SceneHit { id, role })); + assert_eq!(scene.hit(point), Some(id)); +} + +fn occurrence(p: &mut Project, parent: Id, body: OccurrenceBody) -> Id { + let mut element = p + .make_element(Kind::Occurrence, Layer::Oa, "Hit target", Some(parent)) + .unwrap(); + let id = element.id; + *element.shared_mut().unwrap() = SharedBody::Occurrence { + body, + precondition: String::new(), + postcondition: String::new(), + }; + p.elements.insert(id, element); + p.elements + .get_mut(&parent) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} + +fn exchange_mut(p: &mut Project, id: Id) -> &mut OccurrenceBody { + let SharedBody::Occurrence { body, .. } = + p.elements.get_mut(&id).unwrap().shared_mut().unwrap() + else { + panic!("expected occurrence") + }; + body +} + +#[test] +fn exact_headers_include_unassigned_lifelines_but_guides_are_not_headers() { + let mut f = fixture(); + *f.project + .elements + .get_mut(&f.receiver) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Lifeline { represented: None }; + let s = scene(&f); + assert!(!s.lanes.contains_key(&f.receiver)); + assert_eq!(s.render_lanes[&f.receiver], 400.); + for (id, x) in [(f.sender, 160.), (f.receiver, 400.)] { + // The actual header is x-102..x+102, y15..85, including its padding. + for point in [Point::new(x - 101., 16.), Point::new(x + 101., 84.)] { + hit(&s, point, id, SceneHitRole::LifelineHeader); + } + hit(&s, Point::new(x, 95.), id, SceneHitRole::LifelineGuide); + } +} + +#[test] +fn repeated_exchange_occurrences_keep_distinct_message_body_and_label_hits() { + let f = fixture(); + let s = scene(&f); + assert_eq!(s.rows[&f.call].1, 148.); + assert_eq!(s.rows[&f.reply].1, 240.); + for (id, y) in [(f.call, 148.), (f.reply, 240.)] { + hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody); + hit( + &s, + Point::new(180., y - 16.), + id, + SceneHitRole::MessageLabel, + ); + assert_ne!(id, f.exchange); + } +} + +#[test] +fn self_call_elbow_and_receive_endpoint_are_messages_not_execution_hits() { + let mut f = fixture(); + for id in [f.call, f.reply] { + let OccurrenceBody::Exchange { + sender, + receiver, + exchange, + .. + } = exchange_mut(&mut f.project, id) + else { + unreachable!() + }; + *sender = Some(f.sender); + *receiver = Some(f.sender); + *exchange = None; + } + let s = scene(&f); + let call = s.lines.iter().find(|l| l.id == f.call).unwrap(); + assert_eq!(call.points.len(), 4); + assert_eq!(call.points[0].y, 148.); + assert_eq!(call.points[3].y, 172.); + hit( + &s, + Point::new(call.points[1].x, 160.), + f.call, + SceneHitRole::MessageBody, + ); + hit(&s, call.points[3], f.call, SceneHitRole::MessageBody); + hit( + &s, + Point::new(180., 132.), + f.call, + SceneHitRole::MessageLabel, + ); + let bar = s.activations.iter().find(|a| a.call == f.call).unwrap(); + hit( + &s, + Point::new(bar.rect.x + 2., 190.), + f.call, + SceneHitRole::Activation, + ); +} + +#[test] +fn activation_shoulder_beats_another_calls_label_and_broad_message_tolerance() { + let mut f = fixture(); + let mut inner = f.project.elements[&f.call].clone(); + inner.id = Id::new_v4(); + inner.alias = "inner_call_hit".into(); + inner.name = "Pressure verification with a descriptive operation label".into(); + let inner_id = inner.id; + f.project.elements.insert(inner_id, inner); + let mut inner_reply = f.project.elements[&f.reply].clone(); + inner_reply.id = Id::new_v4(); + inner_reply.alias = "inner_reply_hit".into(); + let reply_id = inner_reply.id; + f.project.elements.insert(reply_id, inner_reply); + let OccurrenceBody::Exchange { reply_to, .. } = exchange_mut(&mut f.project, reply_id) else { + unreachable!() + }; + *reply_to = Some(inner_id); + *f.project + .elements + .get_mut(&f.owner) + .unwrap() + .steps_mut() + .unwrap() = vec![f.call, inner_id, reply_id, f.reply]; + let mut s = scene(&f); + let inner = s.lines.iter().find(|l| l.id == inner_id).unwrap(); + let outer = s.activations.iter().find(|a| a.call == f.call).unwrap(); + let shoulder = Point::new(outer.rect.x + 1., 225.); + assert!(outer.rect.contains(shoulder)); + assert!(inner.label_bounds().contains(shoulder)); + let endpoint = *inner.points.last().unwrap(); + for scale in [1., 0.05, 0.0003] { + s.scale = scale; + hit(&s, shoulder, f.call, SceneHitRole::Activation); + hit(&s, endpoint, inner_id, SceneHitRole::MessageBody); + hit( + &s, + Point::new(180., 225.), + inner_id, + SceneHitRole::MessageLabel, + ); + } +} + +#[test] +fn create_delete_markers_are_distinct_from_their_own_message_body_and_label() { + let mut f = fixture(); + for (id, kind) in [ + (f.call, MessageKind::Create), + (f.reply, MessageKind::Delete), + ] { + let OccurrenceBody::Exchange { + sender, + receiver, + message_kind, + reply_to, + .. + } = exchange_mut(&mut f.project, id) + else { + unreachable!() + }; + *sender = Some(f.sender); + *receiver = Some(f.receiver); + *message_kind = kind; + *reply_to = None; + } + let s = scene(&f); + for (id, y, label_y) in [(f.call, 148., 118.), (f.reply, 240., 224.)] { + hit(&s, Point::new(400., y), id, SceneHitRole::LifecycleMarker); + hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody); + hit( + &s, + Point::new(180., label_y), + id, + SceneHitRole::MessageLabel, + ); + } + // Capsule boundary owns its contact with the incoming Create arrow. + hit( + &s, + Point::new(330., 148.), + f.call, + SceneHitRole::LifecycleMarker, + ); +} + +#[test] +fn tiny_scale_nearest_hit_retains_the_winning_marker_or_message_role() { + let mut f = fixture(); + let OccurrenceBody::Exchange { + sender, + receiver, + message_kind, + reply_to, + .. + } = exchange_mut(&mut f.project, f.reply) + else { + unreachable!() + }; + *sender = Some(f.sender); + *receiver = Some(f.receiver); + *message_kind = MessageKind::Delete; + *reply_to = None; + let mut s = scene(&f); + s.scale = 0.0003; + // Both are outside exact strokes/labels. Comparing all broad candidates + // chooses the actual nearer shape, even when their UUID is identical. + hit( + &s, + Point::new(280., 246.), + f.reply, + SceneHitRole::MessageBody, + ); + hit( + &s, + Point::new(400., 265.), + f.reply, + SceneHitRole::LifecycleMarker, + ); + hit( + &s, + Point::new(280., 154.), + f.call, + SceneHitRole::MessageBody, + ); +} + +#[test] +fn action_frame_and_use_boxes_do_not_advertise_message_or_header_roles() { + for body in [ + OccurrenceBody::Action { + action: None, + lifeline: None, + }, + OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![], + }, + OccurrenceBody::ScenarioUse { + scenario: None, + bindings: vec![], + }, + OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + operation: None, + reply_to: None, + values: vec![], + }, + ] { + let mut f = fixture(); + let id = occurrence(&mut f.project, f.owner, body); + let s = scene(&f); + let item = s.boxes.iter().find(|b| b.id == id).unwrap(); + hit( + &s, + item.rect.position() + iced::Vector::new(15., 15.), + id, + SceneHitRole::Box, + ); + } +} + +#[test] +fn winning_primitive_role_does_not_come_from_its_shared_uuid() { + let f = fixture(); + let mut s = scene(&f); + // A controlled overlapping primitive verifies reverse box order without + // inventing a core alias. Its ID intentionally equals a real header's ID. + s.boxes.push(BoxItem { + id: f.sender, + role: SceneHitRole::Box, + rect: Rectangle::new(Point::new(150., 20.), Size::new(20., 20.)), + title: "Overlay control".into(), + detail: String::new(), + marker: None, + }); + hit(&s, Point::new(160., 30.), f.sender, SceneHitRole::Box); + hit( + &s, + Point::new(180., 30.), + f.sender, + SceneHitRole::LifelineHeader, + ); +} + +#[test] +fn invalid_points_never_produce_a_semantic_or_spacing_hit() { + let f = fixture(); + let s = scene(&f); + for point in [ + Point::new(f32::NAN, 148.), + Point::new(280., f32::NAN), + Point::new(f32::INFINITY, 148.), + Point::new(280., f32::NEG_INFINITY), + ] { + assert_eq!(s.hit_item(point), None); + assert_eq!(s.hit(point), None); + } +} diff --git a/crates/oa-desktop/src/behavior/scenario_label.rs b/crates/oa-desktop/src/behavior/scenario_label.rs new file mode 100644 index 00000000..b707789e --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_label.rs @@ -0,0 +1,430 @@ +//! Unwired single-line scenario label experiment. No renderer or schema is changed. +//! +//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale +//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache. +//! This adapter uses that same allocator/font system/attributes, not a character +//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite. +//! Experimental parity requires settled fonts and a fresh renderer/cache after +//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely +//! remeasuring here cannot repair its cached glyph buffers after dynamic loading. +//! A RenderSession is caller attestation of that lifecycle, not its enforcement. +//! No fonts may be loaded between measurement, guarded access and actual draw. +//! +//! Integration seam: after lifecycle/activation endpoint alignment, a caller +//! supplies a usable text width in world units and retains one cache per live +//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents +//! consume the returned Label. Position/color do not invalidate measurement. +//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy. +//! It measures layout advances, not raster ink; overhang/clip/activation-border +//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px +//! renderer is deliberately untouched for a later baseline comparison. +use iced::advanced::{ + graphics::text as graphics_text, + text::{Alignment, LineHeight, Shaping}, +}; +use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text}; +use std::{collections::VecDeque, ops::Range, sync::Arc}; +use unicode_segmentation::UnicodeSegmentation; + +const MAX_SOURCE_BYTES: usize = 64 * 1024; +const MAX_GRAPHEMES: usize = 4096; +const MAX_ENTRIES: usize = 2; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct Style { + pub font: Font, + pub size: Pixels, + pub line_height: LineHeight, + pub shaping: Shaping, +} + +impl Default for Style { + fn default() -> Self { + let text = Text::default(); + Self { + font: text.font, + size: Pixels(12.), + line_height: text.line_height, + shaping: text.shaping, + } + } +} + +impl Style { + fn scaled(self, zoom: f32) -> (f32, f32) { + let size = Pixels(self.size.0 * zoom); + let line_height = match self.line_height { + LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)), + LineHeight::Relative(value) => LineHeight::Relative(value), + }; + (size.0, line_height.to_absolute(size).0) + } +} + +/// Explicit experimental contract: the caller finishes loading fonts and starts +/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed. +/// This module cannot inspect or reset the actual renderer's text cache. +#[derive(Clone, Debug)] +pub(super) struct RenderSession { + version: graphics_text::Version, + identity: Arc<()>, +} + +impl RenderSession { + pub fn after_fonts_settled_and_renderer_reset() -> Result { + let fonts = graphics_text::font_system() + .read() + .map_err(|_| Error::FontSystemUnavailable)?; + Ok(Self { + version: fonts.version(), + identity: Arc::new(()), + }) + } +} + +#[derive(Clone, Debug)] +pub(super) struct Label { + source: String, + displayed: String, + /// Original-label bytes represented before any added ellipsis. Includes + /// collapsed whitespace, but `retains_any` never counts whitespace as text. + retained_source: Range, + /// World-space layout advances, at origin; not glyph-ink containment. + bounds: Rectangle, + style: Style, + measured_session: RenderSession, + measured_zoom: f32, +} + +impl Label { + fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> { + if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) { + return Err(Error::StaleRenderSession); + } + if !zoom.is_finite() || zoom != self.measured_zoom { + return Err(Error::StaleZoom); + } + let fonts = graphics_text::font_system() + .read() + .map_err(|_| Error::FontSystemUnavailable)?; + if fonts.version() != self.measured_session.version || fonts.version() != session.version { + return Err(Error::FontSetChanged); + } + Ok(()) + } + + pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> { + self.check(session, zoom)?; + Ok(&self.displayed) + } + + pub fn retained_source( + &self, + session: &RenderSession, + zoom: f32, + ) -> Result, Error> { + self.check(session, zoom)?; + Ok(self.retained_source.clone()) + } + + pub fn retains_any( + &self, + session: &RenderSession, + zoom: f32, + field: Range, + ) -> Result { + self.check(session, zoom)?; + if field.start > field.end || self.source.get(field.clone()).is_none() { + return Ok(false); + } + let end = field.end.min(self.retained_source.end); + Ok(field.start < end + && self + .source + .get(field.start..end) + .is_some_and(|text| text.chars().any(|c| !c.is_whitespace()))) + } + + /// Validate finite world position and rectangle endpoints only. Camera + /// transforms and raster bounds remain a separate integration contract. + pub fn bounds_at( + &self, + session: &RenderSession, + zoom: f32, + position: Point, + ) -> Result { + self.check(session, zoom)?; + translated_bounds(position, self.bounds.size()) + } + + /// Paint under the same positive uniform zoom used for `Cache::resolve`. + /// No per-label wrapping or implicit text maximum is introduced. + /// The returned Text must be consumed immediately in this session/zoom; + /// retaining raw Text past another font load/zoom change bypasses the guard. + pub fn canvas_text( + &self, + session: &RenderSession, + zoom: f32, + position: Point, + color: Color, + ) -> Result { + self.bounds_at(session, zoom, position)?; + Ok(Text { + content: self.displayed.clone(), + position, + color, + size: self.style.size, + line_height: self.style.line_height, + font: self.style.font, + shaping: self.style.shaping, + max_width: f32::INFINITY, + ..Text::default() + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Error { + InvalidMetrics, + SourceBudget, + GraphemeBudget, + FontSystemUnavailable, + InvalidMeasurement, + InvalidPosition, + StaleZoom, + StaleRenderSession, + FontSetChanged, +} + +struct Entry { + source: String, + style: Style, + zoom: f32, + width: f32, + label: Arc