From 457a2c28db9949194d611d42338a8e324551c8d8 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Sat, 12 Sep 2026 23:46:57 -0700 Subject: [PATCH] Prototype pure saved Scenario spacing command previews --- crates/oa-desktop/src/behavior/scenario.rs | 3 + .../src/behavior/scenario/spacing.rs | 151 +++++ .../src/behavior/scenario/spacing/tests.rs | 522 ++++++++++++++++++ 3 files changed, 676 insertions(+) create mode 100644 crates/oa-desktop/src/behavior/scenario/spacing.rs create mode 100644 crates/oa-desktop/src/behavior/scenario/spacing/tests.rs diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 64a986b6..0e4db544 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -5,7 +5,10 @@ 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; use super::scenario_layout::{ Representation, presentation::{self, ScenarioPresentation}, diff --git a/crates/oa-desktop/src/behavior/scenario/spacing.rs b/crates/oa-desktop/src/behavior/scenario/spacing.rs new file mode 100644 index 00000000..21628dbf --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/spacing.rs @@ -0,0 +1,151 @@ +//! Pure Saved-only spacing preparation. Delta is measured from the original +//! pointer in the captured camera's world coordinates, never from the last +//! preview. The controller must own document/revision/visit, camera and hit-role +//! admission; this module neither dispatches commands nor changes selection. +use crate::behavior::scenario_layout::{ + GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve, +}; +use oa_core::{ + Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition, + ScenarioMessagePosition, SharedBody, execute_batch, +}; + +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Target { + Lane(Id), + Message(Id), +} +impl Target { + fn id(self) -> Id { + match self { + Self::Lane(id) | Self::Message(id) => id, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Refusal { + InvalidSavedView(Id), + TargetNotRendered(Target), + InvalidDelta, + Command(String), + Geometry(Vec), +} + +pub(super) struct Drag { + original: Project, + diagram: Id, + target: Target, + origin: f32, +} + +pub(super) struct Preview { + pub(super) command: Command, + pub(super) project: Project, + pub(super) geometry: ResolvedScenarioPlan, +} + +/// Capture an accepted, actually drawn target. A Saved view displaying automatic +/// fallback is not an editable original, even if the target appears in fallback. +pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result { + let representation = Representation::Saved { diagram }; + let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?; + let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?; + let element = project.elements.get(&target.id()); + let origin = match target { + Target::Lane(id) + if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) => + { + // Unassigned lifelines still have drawn headers. `scene.lanes` + // excludes them only from semantic message-endpoint hit testing. + geometry.scene.render_lanes.get(&id).copied() + } + Target::Message(id) + if element.is_some_and(|e| { + matches!( + e.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { .. }, + .. + }) + ) + }) => + { + // A lifecycle row can denote receive y; the public position means + // send y. Read the actual outgoing endpoint, including self calls. + geometry + .scene + .lines + .iter() + .find(|line| line.id == id) + .and_then(|line| line.points.first()) + .map(|point| point.y) + } + _ => None, + } + .ok_or(Refusal::TargetNotRendered(target))?; + if !origin.is_finite() { + return Err(Refusal::TargetNotRendered(target)); + } + Ok(Drag { + original: project.clone(), + diagram, + target, + origin, + }) +} + +impl Drag { + /// None means there is no command to commit. In particular +0/-0, return to + /// start, and sub-precision motion never materialize a missing override or + /// normalize an existing explicit value. Document/redo are never touched. + pub(super) fn prepare(&self, original_delta: f32) -> Result, Refusal> { + if !original_delta.is_finite() { + return Err(Refusal::InvalidDelta); + } + if original_delta == 0. { + return Ok(None); + } + let position = (f64::from(self.origin) + f64::from(original_delta)) as f32; + if position == self.origin { + return Ok(None); + } + let command = match self.target { + Target::Lane(lifeline) => Command::SetScenarioLanePosition { + diagram: self.diagram, + position: ScenarioLanePosition { + lifeline, + x: position, + }, + }, + Target::Message(occurrence) => Command::SetScenarioMessagePosition { + diagram: self.diagram, + position: ScenarioMessagePosition { + address: OccurrenceEndpoint { + references: vec![], + occurrence, + }, + send_y: position, + }, + }, + }; + let project = execute_batch(&self.original, std::slice::from_ref(&command)) + .map_err(|error| Refusal::Command(error.to_string()))?; + let geometry = resolve( + &project, + Representation::Saved { + diagram: self.diagram, + }, + self.target.id(), + ) + .map_err(Refusal::Geometry)?; + Ok(Some(Preview { + command, + project, + geometry, + })) + } +} diff --git a/crates/oa-desktop/src/behavior/scenario/spacing/tests.rs b/crates/oa-desktop/src/behavior/scenario/spacing/tests.rs new file mode 100644 index 00000000..3fcda05e --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario/spacing/tests.rs @@ -0,0 +1,522 @@ +use super::*; +use crate::behavior::scenario_layout::Diagnostic; +use crate::document::Document; +use oa_core::{ + Diagram, DiagramLayout, ProjectionKind, ScenarioLayout, language, source_project::ProjectLimits, +}; + +fn id(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} + +fn fixture(layout: Option) -> (Document, Id, Id) { + let mut project = language::parse(FIXTURE).unwrap(); + let owner = id(&project, "conversation"); + let mut add = |alias: &str, layout: Option| { + let diagram = Id::new_v4(); + project.diagrams.insert( + diagram, + Diagram { + id: diagram, + alias: alias.into(), + name: alias.into(), + description: String::new(), + layer: oa_core::Layer::Oa, + projection: ProjectionKind::Scenario { scenario: owner }, + nodes: Default::default(), + routes: vec![], + view_options: Default::default(), + layout: layout.map(DiagramLayout::Scenario), + }, + ); + diagram + }; + let a = add("saved_a", layout); + let b = add("saved_b", Some(ScenarioLayout::default())); + let document = Document::from_project(project, ProjectLimits::default()).unwrap(); + (document, a, b) +} + +fn resolved(project: &Project, diagram: Id, selected: Id) -> ResolvedScenarioPlan { + resolve(project, Representation::Saved { diagram }, selected).unwrap() +} + +fn same_geometry(a: &ResolvedScenarioPlan, b: &ResolvedScenarioPlan) { + assert_eq!(a.bounds, b.bounds); + assert_eq!(a.lanes, b.lanes); + assert_eq!(a.plan.frames, b.plan.frames); + assert_eq!(a.plan.scopes, b.plan.scopes); + let (a, b) = (&a.scene, &b.scene); + assert_eq!(a.rows, b.rows); + assert_eq!(a.sequence_bounds, b.sequence_bounds); + assert_eq!(a.size, b.size); + assert_eq!(a.layout_origin, b.layout_origin); + assert_eq!(a.lanes, b.lanes); + assert_eq!(a.render_lanes, b.render_lanes); + assert_eq!(a.activations, b.activations); + assert_eq!(a.lifecycle, b.lifecycle); + assert_eq!(a.open_calls, b.open_calls); + let slots = |scene: &crate::behavior::Scene| { + scene + .slots + .iter() + .map(|item| { + ( + item.slot.parent, + item.slot.before, + item.y, + item.label.clone(), + ) + }) + .collect::>() + }; + assert_eq!(slots(a), slots(b)); + let lines = |scene: &crate::behavior::Scene| { + scene + .lines + .iter() + .map(|line| { + ( + line.id, + line.points.clone(), + line.label_at, + line.label.clone(), + line.dashed, + ) + }) + .collect::>() + }; + let boxes = |scene: &crate::behavior::Scene| { + scene + .boxes + .iter() + .map(|item| { + ( + item.id, + item.rect, + item.title.clone(), + item.detail.clone(), + item.marker, + ) + }) + .collect::>() + }; + assert_eq!(lines(a), lines(b)); + assert_eq!(boxes(a), boxes(b)); +} + +fn expected_message(project: &Project, diagram: Id, occurrence: Id, y: f32) -> Project { + // Independent expected mutation: tests begin with no message overrides. + let mut expected = project.clone(); + expected.diagrams.get_mut(&diagram).unwrap().layout = + Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: vec![], + messages: vec![ScenarioMessagePosition { + address: OccurrenceEndpoint { + references: vec![], + occurrence, + }, + send_y: y, + }], + })); + expected +} + +#[test] +fn one_repeated_message_moves_in_one_view_with_exact_document_undo_redo_and_reopen() { + let (mut document, a, b) = fixture(None); + let original = document.snapshot(); + let before = document.project().clone(); + let first = id(&before, "first"); + let second = id(&before, "second"); + let baseline = resolved(&before, a, second); + assert_eq!(baseline.scene.rows[&first].1, 148.); + assert_eq!(baseline.scene.rows[&second].1, 240.); + let untouched = resolved(&before, b, second); + let drag = capture(&before, a, Target::Message(second)).unwrap(); + let preview = drag.prepare(-40.).unwrap().unwrap(); + let expected = expected_message(&before, a, second, 200.); + assert_eq!(preview.project, expected); + assert_eq!(preview.geometry.scene.rows[&first].1, 148.); + assert_eq!(preview.geometry.scene.rows[&second].1, 200.); + let line = preview + .geometry + .scene + .lines + .iter() + .find(|line| line.id == second) + .unwrap(); + assert_eq!(line.points.first().unwrap().y, 200.); + assert_eq!(line.points.last().unwrap().y, 200.); + assert_eq!( + preview.geometry.scene.activations, + baseline.scene.activations + ); + assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle); + same_geometry(&resolved(&preview.project, b, second), &untouched); + assert_eq!(drag.original, before); + assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot())); + assert!(!document.can_undo()); + + let returned_selection = document.execute(preview.command).unwrap(); + assert_eq!( + returned_selection, + Some(a), + "controller must preserve the dragged semantic selection separately" + ); + assert_eq!(document.project(), &expected); + assert_eq!(document.revision(), 1); + let accepted = document.bundle().clone(); + same_geometry(&resolved(document.project(), a, second), &preview.geometry); + assert!(document.undo()); + assert_eq!(document.project(), &before); + assert_eq!(document.bundle(), &original.bundle); + assert!(!document.can_undo()); + assert!(document.redo()); + assert_eq!(document.project(), &expected); + assert_eq!(document.bundle(), &accepted); + let reopened = Document::from_bundle(accepted, ProjectLimits::default()).unwrap(); + assert_eq!(reopened.project(), &expected); + same_geometry(&resolved(reopened.project(), a, second), &preview.geometry); + assert!(!reopened.dirty() && !reopened.can_undo()); +} + +#[test] +fn horizontal_lane_move_changes_actual_header_and_attached_endpoints_only_in_selected_view() { + let (document, a, b) = fixture(None); + let before = document.project(); + let right = id(before, "right"); + let first = id(before, "first"); + let other = resolved(before, b, right); + let preview = capture(before, a, Target::Lane(right)) + .unwrap() + .prepare(100.) + .unwrap() + .unwrap(); + let mut expected = before.clone(); + expected.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout { + lanes: vec![ScenarioLanePosition { + lifeline: right, + x: 500., + }], + messages: vec![], + })); + assert_eq!(preview.project, expected); + assert_eq!(preview.geometry.scene.lanes[&right], 500.); + let header = preview + .geometry + .scene + .boxes + .iter() + .find(|item| item.id == right) + .unwrap(); + assert_eq!(header.rect.center_x(), 500.); + let line = preview + .geometry + .scene + .lines + .iter() + .find(|line| line.id == first) + .unwrap(); + assert_eq!(line.points.first().unwrap().x, 160.); + assert_eq!(line.points.last().unwrap().x, 500.); + assert!(line.points.iter().all(|point| point.y == 148.)); + same_geometry(&resolved(&preview.project, b, right), &other); + assert_eq!(document.project(), before); + assert!(!document.dirty() && !document.can_undo()); +} + +#[test] +fn drawn_unassigned_lifeline_can_move_without_assigning_a_semantic_participant() { + let (document, a, _) = fixture(None); + let mut project = document.project().clone(); + let owner = id(&project, "conversation"); + let lane = project + .add_element( + Kind::Lifeline, + oa_core::Layer::Oa, + "Unassigned header", + Some(owner), + ) + .unwrap(); + let document = Document::from_project(project, ProjectLimits::default()).unwrap(); + let baseline = resolved(document.project(), a, lane); + assert!(!baseline.scene.lanes.contains_key(&lane)); + let before_header = baseline + .scene + .boxes + .iter() + .find(|item| item.id == lane) + .unwrap() + .rect; + let preview = capture(document.project(), a, Target::Lane(lane)) + .unwrap() + .prepare(70.) + .unwrap() + .unwrap(); + let after_header = preview + .geometry + .scene + .boxes + .iter() + .find(|item| item.id == lane) + .unwrap() + .rect; + assert_eq!(after_header.x, before_header.x + 70.); + assert_eq!(after_header.y, before_header.y); + assert_eq!(after_header.size(), before_header.size()); + assert_eq!(preview.project.elements, document.project().elements); + assert_eq!(preview.project.relations, document.project().relations); + assert!(!document.can_undo()); +} + +#[test] +fn self_call_spacing_uses_send_y_and_preserves_reply_and_actual_activation_finish() { + let (document, a, _) = fixture(None); + let before = document.project(); + let call = id(before, "call"); + let reply = id(before, "reply"); + let baseline = resolved(before, a, call); + let preview = capture(before, a, Target::Message(call)) + .unwrap() + .prepare(-22.) + .unwrap() + .unwrap(); + assert_eq!(preview.project, expected_message(before, a, call, 310.)); + let line = preview + .geometry + .scene + .lines + .iter() + .find(|line| line.id == call) + .unwrap(); + assert_eq!(line.points.first().unwrap().y, 310.); + assert_eq!(line.points.last().unwrap().y, 334.); + assert_eq!(preview.geometry.scene.rows[&reply].1, 424.); + assert_eq!(preview.geometry.scene.activations.len(), 1); + let mut expected_activation = baseline.scene.activations.clone(); + expected_activation[0].rect.y = 334.; + expected_activation[0].rect.height = 90.; + assert_eq!(preview.geometry.scene.activations, expected_activation); + assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle); +} + +#[test] +fn feasible_spacing_crossing_remains_authored_with_diagnostic_and_unchanged_steps() { + let (document, a, _) = fixture(None); + let before = document.project(); + let first = id(before, "first"); + let second = id(before, "second"); + let owner = id(before, "conversation"); + let preview = capture(before, a, Target::Message(second)) + .unwrap() + .prepare(-120.) + .unwrap() + .unwrap(); + assert_eq!(preview.project, expected_message(before, a, second, 120.)); + assert_eq!(preview.geometry.scene.rows[&second].1, 120.); + assert_eq!(preview.geometry.scene.rows[&first].1, 148.); + assert_eq!( + preview.project.elements[&owner].steps(), + before.elements[&owner].steps() + ); + let shown = crate::behavior::scenario_layout::presentation::present( + &preview.project, + Representation::Saved { diagram: a }, + second, + ); + assert!(shown.capabilities().coordinate_input); + assert!( + shown + .diagnostics() + .contains(&Diagnostic::SourceOrderCrossing { + scope: owner, + before: first, + after: second + }) + ); +} + +#[test] +fn unsupported_self_call_move_is_refused_and_automatic_fallback_is_not_editable_capture() { + let (document, a, _) = fixture(None); + let before = document.project(); + let call = id(before, "call"); + let drag = capture(before, a, Target::Message(call)).unwrap(); + let Err(Refusal::Geometry(issues)) = drag.prepare(118.) else { + panic!("send450 / receive474 after reply424 must be refused") + }; + assert!(issues.iter().any(|issue| matches!(issue, GeometryIssue::TypedActivationCrossingNotSupported { call: id, .. } if *id == call))); + assert_eq!(&drag.original, before); + let invalid_geometry = expected_message(before, a, call, 450.); + let shown = crate::behavior::scenario_layout::presentation::present( + &invalid_geometry, + Representation::Saved { diagram: a }, + call, + ); + assert!(shown.capabilities().geometry_navigation && !shown.capabilities().coordinate_input); + assert!(matches!( + capture(&invalid_geometry, a, Target::Message(call)), + Err(Refusal::Geometry(_)) + )); + assert!(!document.dirty() && !document.can_undo()); +} + +#[test] +fn invalid_view_foreign_identity_and_latent_message_are_not_spacing_targets() { + let (document, a, b) = fixture(None); + let p = document.project(); + let unfinished = id(p, "unfinished"); + let source = id(p, "source"); + assert!(matches!( + capture(p, Id::nil(), Target::Lane(source)), + Err(Refusal::InvalidSavedView(_)) + )); + for target in [ + Target::Lane(source), + Target::Lane(b), + Target::Message(source), + Target::Message(unfinished), + ] { + assert!( + matches!(capture(p, a, target), Err(Refusal::TargetNotRendered(found)) if found == target) + ); + } + // A stored latent coordinate is not permission to fabricate a painted arrow. + let mut latent = expected_message(p, a, unfinished, 2000.); + let foreign_owner = latent + .add_element(Kind::Scenario, oa_core::Layer::Oa, "Foreign", None) + .unwrap(); + let foreign_lane = latent + .add_element( + Kind::Lifeline, + oa_core::Layer::Oa, + "Foreign lane", + Some(foreign_owner), + ) + .unwrap(); + *latent + .elements + .get_mut(&foreign_lane) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Lifeline { + represented: Some(source), + }; + let latent_document = Document::from_project(latent, ProjectLimits::default()).unwrap(); + assert!(matches!( + capture(latent_document.project(), a, Target::Message(unfinished)), + Err(Refusal::TargetNotRendered(_)) + )); + assert!(matches!( + capture(latent_document.project(), a, Target::Lane(foreign_lane)), + Err(Refusal::TargetNotRendered(_)) + )); +} + +#[test] +fn zero_motion_return_to_start_and_subprecision_preserve_sparse_intent_and_real_redo() { + let parsed = language::parse(FIXTURE).unwrap(); + let left = id(&parsed, "left"); + let second = id(&parsed, "second"); + let layouts = [ + None, + Some(ScenarioLayout::default()), + Some(ScenarioLayout { + lanes: vec![], + messages: vec![ScenarioMessagePosition { + address: OccurrenceEndpoint { + references: vec![], + occurrence: second, + }, + send_y: 240., + }], + }), + Some(ScenarioLayout { + lanes: vec![ScenarioLanePosition { + lifeline: left, + x: -0., + }], + messages: vec![], + }), + ]; + for (index, layout) in layouts.into_iter().enumerate() { + let (mut document, a, _) = fixture(layout); + let owner = id(document.project(), "conversation"); + document + .execute(Command::Rename(owner, "A real redo entry".into())) + .unwrap(); + let redo = document.snapshot(); + assert!(document.undo()); + let before = document.snapshot(); + let revision = document.revision(); + let target = if index == 3 { + Target::Lane(left) + } else { + Target::Message(second) + }; + let drag = capture(document.project(), a, target).unwrap(); + if index == 3 { + let Some(DiagramLayout::Scenario(layout)) = &drag.original.diagrams[&a].layout else { + panic!() + }; + assert_eq!(layout.lanes[0].x.to_bits(), (-0_f32).to_bits()); + } + assert!(drag.prepare(10.).unwrap().is_some()); + for delta in [0., -0.] { + assert!(drag.prepare(delta).unwrap().is_none()); + } + if index != 3 { + assert!(drag.prepare(f32::from_bits(1)).unwrap().is_none()); + } + assert_eq!( + serde_json::to_vec(&drag.original).unwrap(), + serde_json::to_vec(document.project()).unwrap() + ); + assert!(std::sync::Arc::ptr_eq(&before, &document.snapshot())); + assert_eq!(document.bundle(), &before.bundle); + assert_eq!(document.revision(), revision); + assert!(document.can_redo()); + assert!(document.redo()); + assert_eq!(document.project(), &redo.decoded.project); + assert_eq!(document.bundle(), &redo.bundle); + } +} + +#[test] +fn malformed_or_out_of_range_motion_cannot_mutate_original() { + let (document, a, _) = fixture(None); + let drag = capture( + document.project(), + a, + Target::Message(id(document.project(), "second")), + ) + .unwrap(); + for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta))); + } + assert!(matches!(drag.prepare(f32::MAX), Err(Refusal::Command(_)))); + assert_eq!(&drag.original, document.project()); + assert!(!document.dirty() && !document.can_undo()); +} + +const FIXTURE: &str = r#"arcadia 7; +project spacing_probe "Saved spacing probe" id "71000000-0000-4000-8000-000000000001" { + oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {} + oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {} + interaction exchange "Repeated exchange" id "71000000-0000-4000-8000-000000000004" { source @source; target @target; } + oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; } + oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; } + oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; } + oa_occurrence first "First" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; } + oa_occurrence second "Second" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; } + oa_occurrence call "Call" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; } + oa_occurrence reply "Reply" id "71000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; } + oa_occurrence unfinished "Unfinished" id "71000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; } +} +"#;