Prepare complete saved Scenario display fallback adapter

This commit is contained in:
2026-09-12 22:11:36 -07:00
parent 68a81c3f50
commit 7f192f5dc4
4 changed files with 591 additions and 4 deletions
+1 -1
View File
@@ -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;
@@ -6,6 +6,8 @@ use oa_core::{DiagramLayout, ScenarioLayout};
#[cfg(test)]
mod tests;
pub(crate) mod presentation;
type Candidate = BTreeMap<Id, f32>;
#[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 },
}
@@ -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<GeometryIssue>,
},
Unavailable {
requested_issues: Vec<GeometryIssue>,
automatic_issues: Vec<GeometryIssue>,
},
}
#[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<Id>,
status: PresentationStatus,
geometry: Option<ResolvedScenarioPlan>,
// Saved metadata must survive a complete automatic fallback.
latent_messages: Vec<Id>,
}
impl ScenarioPresentation {
pub(crate) fn requested(&self) -> Representation {
self.requested
}
pub(crate) fn owner(&self) -> Option<Id> {
self.owner
}
pub(crate) fn status(&self) -> &PresentationStatus {
&self.status
}
pub(crate) fn geometry_representation(&self) -> Option<Representation> {
self.geometry
.as_ref()
.map(|geometry| geometry.representation)
}
pub(crate) fn bounds(&self) -> Option<Rectangle> {
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<Scene> {
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<Id> {
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<Id> {
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()
}
@@ -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<ScenarioLayout>) -> 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::<Vec<_>>()
};
let lines = |scene: &Scene| {
scene
.lines
.iter()
.map(|line| {
(
line.id,
line.points.clone(),
line.label.clone(),
line.label_at,
line.dashed,
)
})
.collect::<Vec<_>>()
};
let slots = |scene: &Scene| {
scene
.slots
.iter()
.map(|slot| {
(
slot.slot.parent,
slot.slot.before,
slot.y,
slot.label.clone(),
)
})
.collect::<Vec<_>>()
};
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; }; }; }
}
"#;