diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 6bbc1901..39ea21f5 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -23,7 +23,7 @@ pub(crate) mod scenario; mod scenario_activation; // Pure saved-geometry bridge; workspace integration is a separate checkpoint. #[allow(dead_code)] -mod scenario_layout; +pub(crate) mod scenario_layout; mod scenario_lifecycle; pub(crate) mod source_draft; pub(crate) mod state_canvas; diff --git a/crates/oa-desktop/src/behavior/scenario_layout.rs b/crates/oa-desktop/src/behavior/scenario_layout.rs index d1d1e238..f513d740 100644 --- a/crates/oa-desktop/src/behavior/scenario_layout.rs +++ b/crates/oa-desktop/src/behavior/scenario_layout.rs @@ -6,6 +6,8 @@ use oa_core::{DiagramLayout, ScenarioLayout}; #[cfg(test)] mod tests; +pub(crate) mod presentation; + type Candidate = BTreeMap; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -30,13 +32,13 @@ struct MessageAnchors { } #[derive(Clone, Debug, PartialEq, Eq)] -enum Diagnostic { +pub(crate) enum Diagnostic { SourceOrderCrossing { scope: Id, before: Id, after: Id }, FrameExtendsPastFollowingAnchor { frame: Id, following: Id }, } #[derive(Clone, Debug, PartialEq, Eq)] -pub(super) enum GeometryIssue { +pub(crate) enum GeometryIssue { UnknownAnchor(Id), LifecycleGeometryNotSupported { lifeline: Id, scope: Id }, InvalidRepresentation(Id), @@ -474,7 +476,7 @@ impl Plan { /// An appearance identity, never inferred from selection or from the owner alone. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum Representation { +pub(crate) enum Representation { Derived { owner: Id }, Saved { diagram: Id }, } diff --git a/crates/oa-desktop/src/behavior/scenario_layout/presentation.rs b/crates/oa-desktop/src/behavior/scenario_layout/presentation.rs new file mode 100644 index 00000000..8730f79f --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_layout/presentation.rs @@ -0,0 +1,234 @@ +//! Display policy for one requested Scenario representation, without authoring. +//! The resolver remains the sole renderer. Freshness and editor admission belong +//! to the caller; a capability here never bypasses those guards. +use super::{Diagnostic, GeometryIssue, Representation, ResolvedScenarioPlan, resolve}; +use crate::behavior::Scene; +use iced::Rectangle; +use oa_core::{DiagramLayout, Id, Kind, OccurrenceBody, Project, SharedBody}; +use std::collections::BTreeSet; + +#[cfg(test)] +mod tests; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum PresentationStatus { + Ready, + Fallback { + requested_issues: Vec, + }, + Unavailable { + requested_issues: Vec, + automatic_issues: Vec, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PresentationCapabilities { + /// Actual geometry exists for inspection, Fit and geometry-based navigation. + pub(crate) geometry_navigation: bool, + /// Still subject to document, gesture, editor and view-visit admission. + pub(crate) coordinate_input: bool, +} + +pub(crate) struct ScenarioPresentation { + requested: Representation, + owner: Option, + status: PresentationStatus, + geometry: Option, + // Saved metadata must survive a complete automatic fallback. + latent_messages: Vec, +} + +impl ScenarioPresentation { + pub(crate) fn requested(&self) -> Representation { + self.requested + } + pub(crate) fn owner(&self) -> Option { + self.owner + } + pub(crate) fn status(&self) -> &PresentationStatus { + &self.status + } + pub(crate) fn geometry_representation(&self) -> Option { + self.geometry + .as_ref() + .map(|geometry| geometry.representation) + } + pub(crate) fn bounds(&self) -> Option { + self.geometry.as_ref().map(|geometry| geometry.bounds) + } + /// Diagnostics describe the actual displayed geometry. Requested hard failures + /// are retained separately in status(), never presented as automatic warnings. + pub(crate) fn diagnostics(&self) -> &[Diagnostic] { + self.geometry + .as_ref() + .map_or(&[], |geometry| &geometry.plan.diagnostics) + } + pub(crate) fn latent_messages(&self) -> &[Id] { + &self.latent_messages + } + pub(crate) fn capabilities(&self) -> PresentationCapabilities { + PresentationCapabilities { + geometry_navigation: self.geometry.is_some(), + coordinate_input: matches!(self.status, PresentationStatus::Ready), + } + } + pub(in crate::behavior) fn scene(&self) -> Option<&Scene> { + self.geometry.as_ref().map(|geometry| &geometry.scene) + } + pub(in crate::behavior) fn into_scene(self) -> Option { + self.geometry.map(|geometry| geometry.scene) + } +} + +/// Resolve a structurally accepted Project for passive display. This is not a +/// second model validator: identity and resolver-reported structural failures +/// cannot be converted into a more permissive automatic representation. +pub(crate) fn present( + project: &Project, + requested: Representation, + selected: Id, +) -> ScenarioPresentation { + let owner = checked_owner(project, requested); + let Some(owner_id) = owner else { + let id = match requested { + Representation::Derived { owner } => owner, + Representation::Saved { diagram } => diagram, + }; + return ScenarioPresentation { + requested, + owner: None, + geometry: None, + latent_messages: vec![], + status: PresentationStatus::Unavailable { + requested_issues: vec![GeometryIssue::InvalidRepresentation(id)], + automatic_issues: vec![], + }, + }; + }; + match resolve(project, requested, selected) { + Ok(geometry) => ScenarioPresentation { + requested, + owner, + status: PresentationStatus::Ready, + latent_messages: geometry.latent_messages.clone(), + geometry: Some(geometry), + }, + Err(requested_issues) => { + if matches!(requested, Representation::Derived { .. }) + || requested_issues.iter().any(structural_failure) + { + return ScenarioPresentation { + requested, + owner, + geometry: None, + latent_messages: vec![], + status: PresentationStatus::Unavailable { + requested_issues, + automatic_issues: vec![], + }, + }; + } + match resolve( + project, + Representation::Derived { owner: owner_id }, + selected, + ) { + Ok(geometry) => { + let latent_messages = + saved_latent_messages(project, requested, &geometry.scene); + ScenarioPresentation { + requested, + owner, + status: PresentationStatus::Fallback { requested_issues }, + geometry: Some(geometry), + latent_messages, + } + } + Err(automatic_issues) => ScenarioPresentation { + requested, + owner, + geometry: None, + latent_messages: vec![], + status: PresentationStatus::Unavailable { + requested_issues, + automatic_issues, + }, + }, + } + } + } +} + +fn checked_owner(project: &Project, requested: Representation) -> Option { + let owner = match requested { + Representation::Derived { owner } => owner, + Representation::Saved { diagram } => { + let view = project.diagrams.get(&diagram)?; + let owner = view.projection.scenario()?; + if view.id != diagram || project.elements.get(&owner)?.layer() != view.layer { + return None; + } + owner + } + }; + project + .elements + .get(&owner) + .is_some_and(|element| element.kind() == Kind::Scenario) + .then_some(owner) +} + +fn structural_failure(issue: &GeometryIssue) -> bool { + // These violate the existing identity/address/finite-coordinate admission + // contract. Geometry support failures below do not invalidate stored intent. + match issue { + GeometryIssue::InvalidRepresentation(_) + | GeometryIssue::UnknownAnchor(_) + | GeometryIssue::InvalidCoordinate(_) + | GeometryIssue::DuplicateCoordinate(_) + | GeometryIssue::ExpandedAddressNotSupported(_) + | GeometryIssue::NonFinite(_) => true, + GeometryIssue::LifecycleGeometryNotSupported { .. } + | GeometryIssue::CoincidentDistinctEndpoints(_) + | GeometryIssue::TypedActivationOutsideScope { .. } + | GeometryIssue::TypedActivationOutsideLifetime { .. } + | GeometryIssue::TypedActivationCrossingNotSupported { .. } + | GeometryIssue::CompoundMoveNotImplemented(_) + | GeometryIssue::FixedFrameHeaderWouldMove { .. } + | GeometryIssue::OperandScopeOutsideEnclosingInterval { .. } + | GeometryIssue::LifecycleAnchorOutsideRenderScope { .. } + | GeometryIssue::LifetimeInversionNotSupported { .. } => false, + } +} + +fn saved_latent_messages(project: &Project, requested: Representation, scene: &Scene) -> Vec { + let Representation::Saved { diagram } = requested else { + return vec![]; + }; + let Some(DiagramLayout::Scenario(layout)) = project.diagrams[&diagram].layout.as_ref() else { + return vec![]; + }; + let lines: BTreeSet<_> = scene.lines.iter().map(|line| line.id).collect(); + layout + .messages + .iter() + .filter_map(|message| { + let id = message.address.occurrence; + (message.address.references.is_empty() + && scene.rows.contains_key(&id) + && !lines.contains(&id) + && matches!( + project + .elements + .get(&id) + .and_then(|element| element.shared()), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { .. }, + .. + }) + )) + .then_some(id) + }) + .collect() +} diff --git a/crates/oa-desktop/src/behavior/scenario_layout/presentation/tests.rs b/crates/oa-desktop/src/behavior/scenario_layout/presentation/tests.rs new file mode 100644 index 00000000..21acdb1d --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_layout/presentation/tests.rs @@ -0,0 +1,351 @@ +use super::*; +use crate::document::Document; +use oa_core::{ + Command, Diagram, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition, ScenarioLayout, + ScenarioMessagePosition, language, validation, +}; + +fn fixture() -> (Project, Id) { + let project = language::parse(FIXTURE).unwrap(); + assert_admitted(&project); + let owner = named(&project, "conversation"); + (project, owner) +} +fn named(project: &Project, alias: &str) -> Id { + project + .identities() + .into_iter() + .find(|(_, name)| *name == alias) + .unwrap() + .0 +} +fn assert_admitted(project: &Project) { + let errors: Vec<_> = validation::validate(project) + .into_iter() + .filter(|issue| issue.severity == validation::Severity::Error) + .collect(); + assert!(errors.is_empty(), "{errors:?}"); +} +fn save(project: &mut Project, owner: Id, layout: Option) -> Id { + let id = Id::new_v4(); + project.diagrams.insert( + id, + Diagram { + id, + alias: format!("view_{}", project.diagrams.len()), + name: "Saved conversation".into(), + description: String::new(), + layer: project.elements[&owner].layer(), + projection: ProjectionKind::Scenario { scenario: owner }, + view_options: Default::default(), + nodes: Default::default(), + routes: vec![], + layout: layout.map(DiagramLayout::Scenario), + }, + ); + id +} +fn layout(messages: &[(Id, f32)], lanes: &[(Id, f32)]) -> ScenarioLayout { + ScenarioLayout { + lanes: lanes + .iter() + .map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x }) + .collect(), + messages: messages + .iter() + .map(|&(occurrence, send_y)| ScenarioMessagePosition { + address: OccurrenceEndpoint { + occurrence, + references: vec![], + }, + send_y, + }) + .collect(), + } +} +fn same_geometry(a: &Scene, b: &Scene) { + assert!( + !a.activations.is_empty(), + "fallback must include real typed executions" + ); + assert_eq!(a.activations, b.activations); + assert_eq!(a.lifecycle, b.lifecycle); + assert_eq!(a.rows, b.rows); + assert_eq!(a.sequence_bounds, b.sequence_bounds); + assert_eq!(a.render_lanes, b.render_lanes); + assert_eq!(a.lanes, b.lanes); + assert_eq!(a.open_calls, b.open_calls); + assert_eq!((a.size, a.layout_origin), (b.size, b.layout_origin)); + let boxes = |scene: &Scene| { + scene + .boxes + .iter() + .map(|item| { + ( + item.id, + item.rect, + item.title.clone(), + item.detail.clone(), + item.marker, + ) + }) + .collect::>() + }; + let lines = |scene: &Scene| { + scene + .lines + .iter() + .map(|line| { + ( + line.id, + line.points.clone(), + line.label.clone(), + line.label_at, + line.dashed, + ) + }) + .collect::>() + }; + let slots = |scene: &Scene| { + scene + .slots + .iter() + .map(|slot| { + ( + slot.slot.parent, + slot.slot.before, + slot.y, + slot.label.clone(), + ) + }) + .collect::>() + }; + assert_eq!(boxes(a), boxes(b)); + assert_eq!(lines(a), lines(b)); + assert_eq!(slots(a), slots(b)); +} + +#[test] +fn absent_and_empty_saved_layouts_remain_saved_ready_representations() { + let (mut project, owner) = fixture(); + let absent = save(&mut project, owner, None); + let empty = save(&mut project, owner, Some(ScenarioLayout::default())); + assert_admitted(&project); + let automatic = present(&project, Representation::Derived { owner }, owner); + assert_eq!(automatic.status(), &PresentationStatus::Ready); + for diagram in [absent, empty] { + let requested = Representation::Saved { diagram }; + let displayed = present(&project, requested, owner); + assert_eq!(displayed.requested(), requested); + assert_eq!(displayed.geometry_representation(), Some(requested)); + assert_eq!(displayed.owner(), Some(owner)); + assert_eq!(displayed.status(), &PresentationStatus::Ready); + assert!(displayed.capabilities().coordinate_input); + assert_eq!(displayed.bounds(), automatic.bounds()); + same_geometry(displayed.scene().unwrap(), automatic.scene().unwrap()); + } +} + +#[test] +fn successful_crossing_diagnostic_keeps_authored_geometry_and_latent_identity() { + let (mut project, owner) = fixture(); + let first = named(&project, "first"); + let second = named(&project, "second"); + let left = named(&project, "left"); + let unfinished = named(&project, "unfinished"); + let diagram = save( + &mut project, + owner, + Some(layout( + &[(first, 1000.), (unfinished, 2000.)], + &[(left, 900.)], + )), + ); + assert_admitted(&project); + let shown = present(&project, Representation::Saved { diagram }, first); + assert_eq!(shown.status(), &PresentationStatus::Ready); + assert_eq!(shown.scene().unwrap().rows[&first].1, 1000.); + assert_eq!(shown.scene().unwrap().rows[&second].1, 240.); + assert_eq!(shown.scene().unwrap().render_lanes[&left], 900.); + assert!( + shown + .diagnostics() + .contains(&Diagnostic::SourceOrderCrossing { + scope: owner, + before: first, + after: second + }) + ); + assert_eq!(shown.latent_messages(), &[unfinished]); + assert_eq!(shown.scene().unwrap().rows[&unfinished].1, 516.); +} + +#[test] +fn hard_failure_uses_complete_automatic_geometry_but_retains_each_saved_request_and_latent_notice() +{ + let (mut project, owner) = fixture(); + let call = named(&project, "call"); + let reply = named(&project, "reply"); + let unfinished = named(&project, "unfinished"); + let left = named(&project, "left"); + let right = named(&project, "right"); + let authored = layout( + &[(reply, 300.), (unfinished, 2000.)], + &[(left, 900.), (right, -500.)], + ); + let a = save(&mut project, owner, Some(authored.clone())); + let b = save(&mut project, owner, Some(authored)); + assert_admitted(&project); + let before = project.clone(); + let automatic = present(&project, Representation::Derived { owner }, call); + let control = automatic.scene().unwrap(); + assert_eq!(control.render_lanes[&left], 160.); + assert_eq!(control.render_lanes[&right], 400.); + assert_eq!(control.rows[&reply].1, 424.); + assert_eq!(control.activations.len(), 1); + assert_eq!( + ( + control.activations[0].rect.y, + control.activations[0].rect.height + ), + (356., 68.) + ); + for diagram in [a, b] { + let request = Representation::Saved { diagram }; + let shown = present(&project, request, call); + assert_eq!(shown.requested(), request); + assert_eq!( + shown.geometry_representation(), + Some(Representation::Derived { owner }) + ); + assert_eq!( + shown.status(), + &PresentationStatus::Fallback { + requested_issues: vec![GeometryIssue::TypedActivationCrossingNotSupported { + call, + event: reply + }], + } + ); + assert_eq!( + shown.capabilities(), + PresentationCapabilities { + geometry_navigation: true, + coordinate_input: false + } + ); + assert_eq!(shown.latent_messages(), &[unfinished]); + assert!(shown.diagnostics().is_empty()); + assert_eq!(shown.bounds(), automatic.bounds()); + same_geometry(shown.scene().unwrap(), control); + } + assert_eq!(project, before); +} + +#[test] +fn missing_or_incompatible_identity_and_structural_coordinate_errors_are_unavailable() { + let (mut project, owner) = fixture(); + let left = named(&project, "left"); + let diagram = save(&mut project, owner, None); + let missing = Id::new_v4(); + for request in [ + Representation::Saved { diagram: missing }, + Representation::Derived { owner: left }, + ] { + let shown = present(&project, request, owner); + assert_eq!(shown.requested(), request); + assert_eq!(shown.owner(), None); + assert!( + matches!(shown.status(), PresentationStatus::Unavailable { automatic_issues, .. } if automatic_issues.is_empty()) + ); + assert!(shown.scene().is_none() && shown.bounds().is_none()); + assert_eq!( + shown.capabilities(), + PresentationCapabilities { + geometry_navigation: false, + coordinate_input: false + } + ); + } + project.diagrams.get_mut(&diagram).unwrap().projection = + ProjectionKind::Scenario { scenario: missing }; + assert!(matches!( + present(&project, Representation::Saved { diagram }, owner).status(), + PresentationStatus::Unavailable { .. } + )); + // Deliberately invalid-model control, not a warning-admitted geometry failure. + project.diagrams.get_mut(&diagram).unwrap().projection = + ProjectionKind::Scenario { scenario: owner }; + project.diagrams.get_mut(&diagram).unwrap().layout = Some(DiagramLayout::Scenario(layout( + &[], + &[(left, oa_core::MAX_COORDINATE + 1.)], + ))); + assert!( + validation::validate(&project) + .iter() + .any(|issue| issue.severity == validation::Severity::Error) + ); + let before = project.clone(); + let shown = present(&project, Representation::Saved { diagram }, owner); + assert!( + matches!(shown.status(), PresentationStatus::Unavailable { requested_issues, automatic_issues } + if requested_issues == &[GeometryIssue::InvalidCoordinate(left)] && automatic_issues.is_empty()) + ); + assert!(shown.scene().is_none()); + assert_eq!(project, before); +} + +#[test] +fn passive_fallback_preserves_exact_document_source_and_one_step_undo_redo() { + let (mut project, owner) = fixture(); + let first = named(&project, "first"); + let reply = named(&project, "reply"); + let view = save(&mut project, owner, Some(layout(&[(reply, 300.)], &[]))); + assert_admitted(&project); + let mut document = Document::from_project(project, Default::default()).unwrap(); + let initial_project = document.project().clone(); + let initial_source = document.bundle().clone(); + document + .execute(Command::Rename(first, "An intentional prior edit".into())) + .unwrap(); + let accepted_project = document.project().clone(); + let accepted_source = document.bundle().clone(); + let revision = document.revision(); + { + let shown = present( + document.project(), + Representation::Saved { diagram: view }, + first, + ); + assert!(matches!( + shown.status(), + PresentationStatus::Fallback { .. } + )); + assert!(shown.scene().is_some()); + } + assert_eq!(document.project(), &accepted_project); + assert_eq!(document.bundle(), &accepted_source); + assert_eq!(document.revision(), revision); + assert!(document.undo()); + assert_eq!(document.project(), &initial_project); + assert_eq!(document.bundle(), &initial_source); + assert!(document.redo()); + assert_eq!(document.project(), &accepted_project); + assert_eq!(document.bundle(), &accepted_source); +} + +const FIXTURE: &str = r#"arcadia 7; +project display_probe "Scenario display probe" id "70000000-0000-4000-8000-000000000001" { + oa_activity source "Source" id "70000000-0000-4000-8000-000000000002" {} + oa_activity target "Target" id "70000000-0000-4000-8000-000000000003" {} + interaction exchange "Repeated exchange" id "70000000-0000-4000-8000-000000000004" { source @source; target @target; } + oa_scenario conversation "Conversation" id "70000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; } + oa_lifeline left "Left" id "70000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; } + oa_lifeline right "Right" id "70000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; } + oa_occurrence first "First" id "70000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; } + oa_occurrence second "Second" id "70000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; } + oa_occurrence call "Call" id "70000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; } + oa_occurrence reply "Reply" id "70000000-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 "70000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; } +} +"#;