diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index aaa50d9e..d65ad51a 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -11,6 +11,7 @@ mod reference_authoring; #[cfg(test)] mod review_navigation_tests; mod scenario_diagram; +mod scenario_spacing; pub(crate) mod source_rename; mod source_symbols; #[cfg(test)] @@ -1271,6 +1272,9 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + 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 @@ -1330,6 +1334,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 window_resized && let Some((_, _, epoch)) = &mut self.pending_diagram_reveal { *epoch = self.canvas_epoch; @@ -1344,6 +1349,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; @@ -1378,6 +1390,9 @@ impl App { Message::ScenarioCanvas(Scenario::Camera(c, lifetime, Camera::Focus)) if valid_scenario(*c) && *lifetime == scope.epoch => { + if self.behavior.scenario.cancel_spacing() { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } return canvas_workspace::focus_task(scope, Camera::Focus); } Message::TopologyCanvas(Process::Camera(c, lifetime, Camera::Focus)) @@ -1403,6 +1418,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 { @@ -1972,6 +1990,9 @@ impl App { { return Task::none(); } + if self.behavior.scenario.cancel_spacing() { + self.canvas_epoch = self.canvas_epoch.wrapping_add(1); + } match input { Event::Focus | Event::Traverse(_) 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..35981157 --- /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..98656c7a --- /dev/null +++ b/crates/oa-desktop/src/app/scenario_spacing/tests.rs @@ -0,0 +1,272 @@ +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) +} +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/behavior.rs b/crates/oa-desktop/src/behavior.rs index 060119d5..3ec08500 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -21,6 +21,8 @@ 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; @@ -1161,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, @@ -1195,6 +1216,7 @@ fn box_text_clip( struct Line { id: Id, + role: SceneHitRole, points: Vec, label: String, label_at: Point, @@ -1220,6 +1242,7 @@ struct Scene { 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, @@ -1248,6 +1271,10 @@ struct SequenceInput { } #[derive(Clone, Copy)] enum SequencePress { + Spacing { + stamp: scenario::spacing::Stamp, + token: Id, + }, Occurrence { tool: scenario::Tool, slot: scenario::Slot, @@ -1268,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; @@ -1293,6 +1333,7 @@ impl Scene { layout_origin: Point::ORIGIN, selected, interaction: None, + spacing_stamp: None, coordinate_input: true, pending_placement: None, lanes: BTreeMap::new(), @@ -1450,6 +1491,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, @@ -1459,6 +1501,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: if scenario { format!("Lane · {name}") @@ -1552,6 +1599,7 @@ impl Scene { 0, BoxItem { id: *id, + role: SceneHitRole::Box, rect, title: format!("{operator:?} · {}", e.name), detail: String::new(), @@ -1563,6 +1611,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.), @@ -1646,6 +1695,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.), @@ -1654,6 +1704,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.), @@ -1676,6 +1727,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 @@ -1688,6 +1740,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(), @@ -1698,6 +1751,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 @@ -1710,6 +1764,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.), @@ -1775,11 +1830,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() @@ -1789,7 +1854,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() @@ -1799,36 +1867,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() - && Rectangle::new( - line.label_at, - Size::new( - (line.label.chars().count() as f32 * 6.8 + 8.).min(225.), - 25., - ), - ) - .contains(point) + .find(|line| !line.label.is_empty() && line.label_bounds().contains(point)) + .map(|line| SceneHit { + id: line.id, + role: if line.role == SceneHitRole::MessageBody { + SceneHitRole::MessageLabel + } else { + SceneHitRole::OtherLine + }, }) - .map(|line| line.id) }) // 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() @@ -1839,11 +1922,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; } @@ -1857,17 +1943,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 { @@ -1895,15 +1996,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 || !self.coordinate_input { - 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, @@ -1922,7 +2041,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) @@ -1931,9 +2050,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(_), @@ -1946,8 +2065,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, }; @@ -2001,6 +2121,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)) @@ -2030,6 +2192,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, @@ -2121,6 +2299,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, @@ -2323,6 +2513,10 @@ 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/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 0e4db544..4125fc06 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -5,10 +5,8 @@ use super::*; use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; mod camera; -// Pure spacing experiment; no production gesture or command dispatch is wired. pub(crate) mod focus; -#[cfg(test)] -mod spacing; +pub(crate) mod spacing; use super::scenario_layout::{ Representation, presentation::{self, ScenarioPresentation}, @@ -109,6 +107,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), @@ -149,6 +148,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, _) @@ -217,6 +217,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, @@ -373,6 +374,7 @@ impl State { 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; @@ -381,10 +383,12 @@ impl State { } /// 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(); diff --git a/crates/oa-desktop/src/behavior/scenario/spacing.rs b/crates/oa-desktop/src/behavior/scenario/spacing.rs index 21628dbf..bc120208 100644 --- a/crates/oa-desktop/src/behavior/scenario/spacing.rs +++ b/crates/oa-desktop/src/behavior/scenario/spacing.rs @@ -10,16 +10,18 @@ use oa_core::{ 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(super) enum Target { +pub enum Target { Lane(Id), Message(Id), } impl Target { - fn id(self) -> Id { + pub(crate) fn id(self) -> Id { match self { Self::Lane(id) | Self::Message(id) => id, } @@ -27,10 +29,13 @@ impl Target { } #[derive(Debug, PartialEq, Eq)] -pub(super) enum Refusal { +pub(crate) enum Refusal { InvalidSavedView(Id), TargetNotRendered(Target), InvalidDelta, + InvalidGesture, + CameraChanged, + TargetNotHit(Target), Command(String), Geometry(Vec), } @@ -40,10 +45,12 @@ pub(super) struct Drag { 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, } @@ -95,6 +102,7 @@ pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result< diagram, target, origin, + original_geometry: geometry, }) } @@ -144,6 +152,7 @@ impl Drag { .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..850d1645 --- /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: Command, + 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: 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..87d5f13e --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/spacing/controller/tests.rs @@ -0,0 +1,330 @@ +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)); +} 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/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 956df0d8..227b26e6 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -78,11 +78,43 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a }; let tool = state.canvas_tool(owner); let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(); - let shown = presentation::present(p, context.representation, app.selected); - let capabilities = shown.capabilities(); + // 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 = display_notice(p, &shown); - let camera_state = shown.bounds().map(|bounds| presentation.camera(bounds)); + 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, @@ -95,23 +127,56 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a && capabilities.geometry_navigation && super::super::canvas_workspace::keyboard_enabled(app); let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e)); - let canvas: Element<'a, Message> = if let Some(mut scene) = shown.into_scene() { + let raw_canvas: Option> = if let Some(paint) = &preview { + Some( + canvas::Canvas::new(paint.scene) + .width(Fill) + .height(Fill) + .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( - canvas::Canvas::new(scene).width(Fill).height(Fill), - scope, - scale, - keyboard, - camera, + layers, scope, scale, keyboard, camera, ) .pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input), ) @@ -222,7 +287,15 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a )); } 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", @@ -310,6 +383,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), @@ -640,8 +717,24 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a } 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 shown.requested() { + 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), @@ -649,9 +742,9 @@ fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)), }; let mut notice = column![text(title).size(14)].spacing(4); - match shown.status() { + match status { PresentationStatus::Ready => { - for diagnostic in shown.diagnostics().iter().take(3) { + 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.", @@ -666,11 +759,11 @@ fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, }; notice = notice.push(text(explanation).size(12)); } - if shown.diagnostics().len() > 3 { + if diagnostics.len() > 3 { notice = notice.push( text(format!( "{} additional spacing notices are retained.", - shown.diagnostics().len() - 3 + diagnostics.len() - 3 )) .size(12), ); @@ -687,8 +780,8 @@ fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, 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 !shown.latent_messages().is_empty() { - notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", shown.latent_messages().len())).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) @@ -698,6 +791,43 @@ fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, .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 { @@ -789,6 +919,9 @@ 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; + fn sequence_rows<'a>( p: &'a Project, context: Context, 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..3f50d198 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/workspace/spacing_tests.rs @@ -0,0 +1,420 @@ +//! 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 start = screen(&mut mounted, &app, Point::new(400., 252.)); + let end = start + Vector::new(0., 10.); // .5 display scale: +20 authored y. + 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_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_layout.rs b/crates/oa-desktop/src/behavior/scenario_layout.rs index e30e94d2..154129ee 100644 --- a/crates/oa-desktop/src/behavior/scenario_layout.rs +++ b/crates/oa-desktop/src/behavior/scenario_layout.rs @@ -64,7 +64,7 @@ pub(super) struct Plan { pub(super) frames: BTreeMap, pub(super) scopes: BTreeMap, pub(super) height: f32, - diagnostics: Vec, + pub(super) diagnostics: Vec, } pub(super) fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> {