Compose saved Scenario authoring with current Text and input authority

This commit is contained in:
2026-09-23 00:25:20 -07:00
parent 5960563b23
commit 6f4dfc67b3
78 changed files with 14097 additions and 318 deletions
@@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
)
})
.collect();
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
(p, diagram, exchanges, categories, contexts)
}
+1
View File
@@ -81,6 +81,7 @@ fn diagram(
projection,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+4
View File
@@ -9,6 +9,7 @@ pub mod ports;
pub mod projection;
pub mod references;
pub mod routing;
pub mod scenario_layout;
pub mod semantic;
pub mod session;
mod source_defaults;
@@ -20,6 +21,9 @@ pub mod validation;
pub mod view_options;
pub use model::*;
pub use ports::*;
pub use scenario_layout::{
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
};
pub use semantic::*;
pub use session::*;
mod demo;
+27
View File
@@ -1436,6 +1436,7 @@ pub enum ProjectionKind {
FunctionalDataflow,
Capabilities,
StateMachine { machine: Id },
Scenario { scenario: Id },
}
impl ProjectionKind {
pub fn state_machine(self) -> Option<Id> {
@@ -1445,8 +1446,30 @@ impl ProjectionKind {
}
}
/// The semantic owner of a saved Scenario representation.
pub fn scenario(self) -> Option<Id> {
match self {
Self::Scenario { scenario } => Some(scenario),
_ => None,
}
}
/// Explicitly classify architecture views; absence of a state machine is insufficient.
pub fn is_architecture(self) -> bool {
match self {
Self::Overview
| Self::Decomposition
| Self::Allocation
| Self::Traceability
| Self::FunctionalDataflow
| Self::Capabilities => true,
Self::StateMachine { .. } | Self::Scenario { .. } => false,
}
}
pub fn includes(self, kind: Kind) -> bool {
match self {
Self::Scenario { .. } => false,
Self::StateMachine { .. } => {
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
}
@@ -1471,6 +1494,7 @@ impl std::fmt::Display for ProjectionKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::StateMachine { .. } => "State machine",
Self::Scenario { .. } => "Scenario",
Self::Allocation => "Architecture · allocated work",
Self::FunctionalDataflow => "Functional dataflow",
Self::Capabilities => "Missions & capabilities",
@@ -1495,6 +1519,8 @@ pub struct Diagram {
pub nodes: BTreeMap<Id, NodePlacement>,
#[serde(default)]
pub routes: Vec<EdgeRoute>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub layout: Option<crate::scenario_layout::DiagramLayout>,
}
/// Authored presentation for one existing relation or trace in one diagram.
/// Computed endpoints and automatic routing elbows are never persisted here.
@@ -1921,6 +1947,7 @@ impl Project {
view_options: crate::view_options::ViewOptions::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
},
);
id
+6
View File
@@ -278,6 +278,11 @@ fn project_diagram_impl(
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
index: Option<&crate::view_options::ViewIndex>,
) -> ProjectedView {
// Saved Scenario layout is not an architecture/state projection. Its dedicated
// renderer is not wired yet; never interpret its payload through this fallback.
if d.projection.scenario().is_some() {
return ProjectedView::default();
}
let parents = parents(p, d);
let mut nodes = Vec::new();
for n in d.nodes.values() {
@@ -394,6 +399,7 @@ fn project_diagram_impl(
continue;
}
let included = match d.projection {
ProjectionKind::Scenario { .. } => false,
ProjectionKind::StateMachine { machine } => matches!(&r.body,
RelationBody::StateTransition { transition_kind, .. }
if *transition_kind != TransitionKind::Internal
+84 -23
View File
@@ -203,7 +203,81 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
_ => {}
}
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
/// Every semantic/presentation reference in a diagram, excluding its own identities.
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
let mut copy = d.clone();
let mut refs = Vec::new();
visit_diagram_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
d.view_options.visit_references(&mut visit);
match &mut d.projection {
ProjectionKind::StateMachine { machine } => visit(machine),
ProjectionKind::Scenario { scenario } => visit(scenario),
_ => {}
}
if let Some(layout) = &mut d.layout {
layout.visit_references(&mut visit);
}
for route in &mut d.routes {
visit(&mut route.edge);
}
for node in d.nodes.values_mut() {
for anchor in &mut node.port_anchors {
visit(&mut anchor.port);
}
visit(&mut node.element);
}
}
/// Preflight reference-key collisions before the caller changes object identities.
/// Fresh targets are allowed. This is not whole-project admission: unrelated
/// invalid records remain repairable within a transaction. Future typed reference
/// domains must compose their own preflight here before any remap mutation.
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
if old == new {
return Ok(());
}
let remap = |id: Id| if id == old { new } else { id };
for diagram in p.diagrams.values() {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
continue;
};
let mut lanes = std::collections::BTreeMap::new();
for lane in &layout.lanes {
let affected = lane.lifeline == old;
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario lane position.".into(),
));
}
}
let mut messages = std::collections::BTreeMap::new();
for message in &layout.messages {
let mut address = message.address.clone();
let affected = address.occurrence == old || address.references.contains(&old);
address.occurrence = remap(address.occurrence);
for reference in &mut address.references {
*reference = remap(*reference);
}
if let Some(previous_affected) = messages.insert(address, affected)
&& (affected || previous_affected)
{
return Err(ModelError(
"Identity remap would collide with a Scenario message position.".into(),
));
}
}
}
Ok(())
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
check_remap(p, old, new)?;
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
@@ -219,31 +293,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
for d in p.diagrams.values_mut() {
d.view_options.visit_references(|id| {
visit_diagram_refs(d, |id| {
if *id == old {
*id = new;
}
});
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
*machine = new;
}
for route in &mut d.routes {
if route.edge == old {
route.edge = new;
}
}
for n in d.nodes.values_mut() {
for anchor in &mut n.port_anchors {
if anchor.port == old {
anchor.port = new;
}
}
if n.element == old {
n.element = new;
}
}
}
for a in p.artifact_links.values_mut() {
if a.owner == old {
@@ -265,6 +319,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
}
Ok(())
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
@@ -339,7 +394,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
for d in p.diagrams.values() {
if d.projection
.state_machine()
.is_some_and(|machine| removed.contains(&machine))
.or_else(|| d.projection.scenario())
.is_some_and(|owner| removed.contains(&owner))
{
removed.insert(d.id);
}
@@ -412,6 +468,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.view_options.retain_references(|id| !removed.contains(id));
crate::scenario_layout::prune_deleted(d, &removed);
d.nodes.retain(|id, _| !removed.contains(id));
d.routes.retain(|route| !removed.contains(&route.edge));
for node in d.nodes.values_mut() {
@@ -589,3 +646,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
}
}
#[cfg(test)]
#[path = "references/scenario_layout_tests.rs"]
mod scenario_layout_tests;
@@ -0,0 +1,388 @@
use super::*;
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
lanes: [Id; 2],
messages: [Id; 2],
views: [Id; 2],
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
panic!("expected authored Scenario positions")
};
layout
}
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&view).unwrap().layout
else {
panic!("expected authored Scenario positions")
};
layout
}
fn admitted(project: &Project) {
let errors: Vec<_> = crate::validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == crate::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
.unwrap();
let lanes = ["Sender", "Receiver"].map(|name| {
project
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
.unwrap()
});
let messages = ["First message", "Second message"].map(|name| {
let id = project
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
.unwrap();
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: Some(lanes[0]),
receiver: Some(lanes[1]),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
id
});
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = messages.to_vec();
let views = ["delivery_view", "review_view"].map(|alias| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![
ScenarioLanePosition {
lifeline: lanes[0],
x: 120.,
},
ScenarioLanePosition {
lifeline: lanes[1],
x: 420.,
},
],
messages: vec![
ScenarioMessagePosition {
address: address(messages[0]),
send_y: 220.,
},
ScenarioMessagePosition {
address: address(messages[1]),
send_y: 360.,
},
],
})),
},
);
id
});
admitted(&project);
Fixture {
project,
owner,
lanes,
messages,
views,
}
}
#[test]
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
let mut f = fixture();
let before = f.project.clone();
let temporary = Id::new_v4();
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
admitted(&f.project);
assert_eq!(
f.project.elements[&f.owner].steps().unwrap(),
&vec![f.messages[1], f.messages[0]]
);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(
rows.lanes,
vec![
ScenarioLanePosition {
lifeline: f.lanes[1],
x: 120.
},
ScenarioLanePosition {
lifeline: f.lanes[0],
x: 420.
}
]
);
assert_eq!(
rows.messages,
vec![
ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 220.
},
ScenarioMessagePosition {
address: address(f.messages[0]),
send_y: 360.
}
]
);
}
// Reverse the swaps: no values or identities other than those requested drift.
for ids in [f.lanes, f.messages] {
f.project = crate::execute_batch(
&f.project,
&[
Command::Reidentify(ids[0], temporary),
Command::Reidentify(ids[1], ids[0]),
Command::Reidentify(temporary, ids[1]),
],
)
.unwrap();
}
assert_eq!(f.project, before);
}
#[test]
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
for message in [false, true] {
let mut f = fixture();
let [old, new] = if message { f.messages } else { f.lanes };
let before = f.project.clone();
assert!(check_remap(&f.project, old, new).is_err());
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
}
}
#[test]
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
let mut f = fixture();
let fresh = Id::new_v4();
// A repair candidate can already have a dangling coordinate key. It must not
// silently merge with the live lane, even though the destination ID is vacant.
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
let before = f.project.clone();
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
assert_eq!(f.project, before);
}
#[test]
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
let mut f = fixture();
let [old, new] = [Id::new_v4(), Id::new_v4()];
let rows = layout_mut(&mut f.project, f.views[0]);
// Future/invalid expanded candidates still participate in reference safety.
rows.messages[0].address.references = vec![old];
rows.messages[1].address = OccurrenceEndpoint {
references: vec![new],
occurrence: f.messages[0],
};
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
let before = f.project.clone();
assert!(remap_references(&mut f.project, old, new).is_err());
assert_eq!(f.project, before);
let fresh = Id::new_v4();
remap_references(&mut f.project, old, fresh).unwrap();
let rows = layout(&f.project, f.views[0]);
assert_eq!(rows.messages[0].address.references, vec![fresh]);
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
assert_eq!(rows.messages[1].address.references, vec![new]);
}
#[test]
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
let mut f = fixture();
let other_owner = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
.unwrap();
let mut other_view = f.project.diagrams[&f.views[0]].clone();
other_view.id = Id::new_v4();
other_view.alias = "independent_view".into();
other_view.projection = ProjectionKind::Scenario {
scenario: other_owner,
};
other_view.layout = None;
f.project.diagrams.insert(other_view.id, other_view.clone());
admitted(&f.project);
let fresh_owner = Id::new_v4();
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
.unwrap()
.project;
for view in f.views {
assert_eq!(
f.project.diagrams[&view].projection.scenario(),
Some(fresh_owner)
);
}
let impact = deletion_impact(&f.project, fresh_owner);
assert!(
f.views
.iter()
.chain(&f.lanes)
.chain(&f.messages)
.all(|id| impact.contains(id))
);
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
delete_cascade(&mut f.project, fresh_owner);
assert_eq!(f.project.diagrams[&other_view.id], other_view);
assert!(
f.views
.iter()
.all(|id| !f.project.diagrams.contains_key(id))
);
admitted(&f.project);
}
#[test]
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
let mut f = fixture();
let lane = f.project.elements[&f.lanes[0]].clone();
let message = f.project.elements[&f.messages[0]].clone();
delete_cascade(&mut f.project, lane.id);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
// Missing endpoint assignment does not erase a still-live message's spacing.
assert_eq!(rows.messages.len(), 2);
}
let mut pending = BTreeSet::new();
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
// Pruning happens during deletion, before later batch commands can recreate IDs.
assert!(
layout(&f.project, f.views[0])
.messages
.iter()
.all(|row| row.address.occurrence != message.id)
);
f.project.elements.insert(lane.id, lane);
f.project.elements.insert(message.id, message);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.insert(0, f.messages[0]);
finish_deletions(&mut f.project, &pending);
for view in f.views {
let rows = layout(&f.project, view);
assert_eq!(rows.lanes.len(), 1);
assert_eq!(
rows.messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
}
admitted(&f.project);
}
#[test]
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
let mut f = fixture();
let unrelated = Id::new_v4();
let rows = layout_mut(&mut f.project, f.views[1]);
let invalid = ScenarioLanePosition {
lifeline: unrelated,
x: MAX_COORDINATE * 2.,
};
rows.lanes.extend([invalid.clone(), invalid]);
let fresh = Id::new_v4();
let untouched = f.project.diagrams[&f.views[1]].clone();
let before = f.project.clone();
remap_references(&mut f.project, f.owner, f.owner).unwrap();
assert_eq!(f.project, before);
// The duplicate lane records do not involve the remapped message identity.
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
assert_eq!(
layout(&f.project, f.views[1]).lanes,
layout_from(&untouched).lanes
);
assert_eq!(
layout(&f.project, f.views[1]).messages[0]
.address
.occurrence,
fresh
);
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
}
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("missing layout")
};
layout
}
#[test]
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
let mut f = fixture();
// Expanded addresses are not admitted in this slice, but deleting their
// explicit references must still make an intermediate repair candidate safe.
layout_mut(&mut f.project, f.views[0]).messages[0]
.address
.references = vec![f.lanes[0]];
delete_cascade(&mut f.project, f.lanes[0]);
assert_eq!(
layout(&f.project, f.views[0]).messages,
vec![ScenarioMessagePosition {
address: address(f.messages[1]),
send_y: 360.
}]
);
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
assert!(f.project.elements.contains_key(&f.messages[0]));
admitted(&f.project);
}
+429
View File
@@ -0,0 +1,429 @@
//! Authored Scenario presentation. Structural admission never changes event order.
use crate::{
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
Project, SharedBody, validation,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DiagramLayout {
Scenario(ScenarioLayout),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLayout {
#[serde(default)]
pub lanes: Vec<ScenarioLanePosition>,
#[serde(default)]
pub messages: Vec<ScenarioMessagePosition>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioLanePosition {
pub lifeline: Id,
pub x: f32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScenarioMessagePosition {
pub address: OccurrenceEndpoint,
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
pub send_y: f32,
}
impl DiagramLayout {
/// Visit every explicit reference, including unsupported paths in a candidate.
/// The caller owns remap preflight and final atomic validation.
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
match self {
Self::Scenario(layout) => {
for lane in &mut layout.lanes {
visit(&mut lane.lifeline);
}
for message in &mut layout.messages {
for reference in &mut message.address.references {
visit(reference);
}
visit(&mut message.address.occurrence);
}
}
}
}
}
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
/// One payload plus one record per lane/message. No graph objects are introduced.
pub fn record_count(diagram: &Diagram) -> usize {
match &diagram.layout {
None => 0,
Some(DiagramLayout::Scenario(layout)) => 1usize
.saturating_add(layout.lanes.len())
.saturating_add(layout.messages.len()),
}
}
fn coordinate(value: f32) -> bool {
value.is_finite() && value.abs() <= MAX_COORDINATE
}
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
let scenario = diagram
.projection
.scenario()
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
if !p
.elements
.get(&scenario)
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
{
return Err(ModelError(
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
));
}
Ok(scenario)
}
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
p.elements.get(&lifeline).is_some_and(|element| {
element.kind() == Kind::Lifeline
&& element.parent == Some(owner)
&& element.layer() == layer
})
}
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
/// cycles terminate, and malformed parent/step links do not manufacture membership.
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
let mut messages = BTreeSet::new();
let mut visited = BTreeSet::new();
let mut pending = vec![owner];
while let Some(scope) = pending.pop() {
if !visited.insert(scope) {
continue;
}
let Some(element) = p.elements.get(&scope) else {
continue;
};
for id in element.steps().into_iter().flatten() {
let Some(item) = p.elements.get(id) else {
continue;
};
if item.parent != Some(scope) || item.layer() != layer {
continue;
}
match item.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
}) => {
messages.insert(*id);
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) => {
for branch in branches {
if p.elements.get(branch).is_some_and(|operand| {
operand.parent == Some(*id)
&& operand.layer() == layer
&& matches!(
operand.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
}) {
pending.push(*branch);
}
}
}
_ => {}
}
}
}
messages
}
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
if diagram.projection.scenario().is_none() {
if diagram.layout.is_some() {
validation::error(
out,
diagram.id,
"scenario_layout_projection",
"layout",
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
);
}
return;
}
let scenario = match owner(p, diagram) {
Ok(owner) => owner,
Err(error) => {
validation::error(
out,
diagram.id,
"scenario_diagram_context",
"projection",
error.0,
);
return;
}
};
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
validation::error(
out,
diagram.id,
"scenario_diagram_content",
"layout",
"Scenario views do not accept architecture node placements or edge routes.",
);
}
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
return;
};
let mut lanes = BTreeSet::new();
for lane in &layout.lanes {
if !lanes.insert(lane.lifeline) {
validation::error(
out,
diagram.id,
"duplicate_scenario_lane",
"layout",
"A Scenario layout has at most one position per lifeline.",
);
}
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
validation::error(
out,
diagram.id,
"scenario_layout_lifeline",
"layout",
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
);
}
if !coordinate(lane.x) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
let members = direct_messages(p, scenario, diagram.layer);
let mut addresses = BTreeSet::new();
for message in &layout.messages {
// Reject expanded paths without cloning or indexing their potentially large contents.
if !message.address.references.is_empty() {
validation::error(
out,
diagram.id,
"scenario_layout_address",
"layout",
"Expanded-reference message positions are not supported; references must be empty.",
);
} else if !addresses.insert(message.address.occurrence) {
validation::error(
out,
diagram.id,
"duplicate_scenario_message",
"layout",
"A Scenario layout has at most one position per message address.",
);
}
if !members.contains(&message.address.occurrence) {
validation::error(
out,
diagram.id,
"scenario_layout_message",
"layout",
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
);
}
if !coordinate(message.send_y) {
validation::error(
out,
diagram.id,
"scenario_layout_coordinate",
"layout",
"Scenario positions must be finite and within the shared coordinate bounds.",
);
}
}
}
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
let diagram = p
.diagrams
.get(&id)
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
let scenario = owner(p, diagram)?;
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
return Err(ModelError(
"Scenario views do not accept architecture node placements or edge routes.".into(),
));
}
Ok((diagram, scenario))
}
fn check_message(
p: &Project,
diagram: &Diagram,
owner: Id,
address: &OccurrenceEndpoint,
) -> Result<(), ModelError> {
if !address.references.is_empty() {
return Err(ModelError(
"Expanded-reference message positions are not supported; references must be empty."
.into(),
));
}
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
}
Ok(())
}
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
let DiagramLayout::Scenario(layout) = diagram
.layout
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
layout
}
/// Target-scoped preparation, not whole-project or whole-layout validation.
/// Unrelated intermediate errors remain repairable by later commands in a batch.
pub fn set_lane(
p: &Project,
diagram: Id,
position: ScenarioLanePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
return Err(ModelError(
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.lanes
.iter()
.filter(|row| row.lifeline == position.lifeline)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate lane position.".into(),
));
}
if let Some(existing) = layout
.lanes
.iter_mut()
.find(|row| row.lifeline == position.lifeline)
{
if existing.x != position.x {
*existing = position;
}
} else {
layout.lanes.push(position);
}
Ok(result)
}
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
if !lane_member(p, owner, current.layer, lifeline) {
return Err(ModelError(
"Removing a lane position requires an owned Lifeline.".into(),
));
}
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.lanes.retain(|row| row.lifeline != lifeline);
}
Ok(result)
}
pub fn set_message(
p: &Project,
diagram: Id,
position: ScenarioMessagePosition,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, &position.address)?;
if !coordinate(position.send_y) {
return Err(ModelError(
"Message position requires a finite bounded send_y coordinate.".into(),
));
}
let mut result = current.clone();
let layout = payload(&mut result);
if layout
.messages
.iter()
.filter(|row| row.address == position.address)
.count()
> 1
{
return Err(ModelError(
"Cannot set an ambiguous duplicate message position.".into(),
));
}
if let Some(existing) = layout
.messages
.iter_mut()
.find(|row| row.address == position.address)
{
if existing.send_y != position.send_y {
*existing = position;
}
} else {
layout.messages.push(position);
}
Ok(result)
}
pub fn remove_message(
p: &Project,
diagram: Id,
address: &OccurrenceEndpoint,
) -> Result<Diagram, ModelError> {
let (current, owner) = editable(p, diagram)?;
check_message(p, current, owner, address)?;
let mut result = current.clone();
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
layout.messages.retain(|row| &row.address != address);
}
Ok(result)
}
/// Prune only identities in the caller's actual deletion closure, never visibility
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
layout
.lanes
.retain(|lane| !deleted.contains(&lane.lifeline));
layout.messages.retain(|message| {
!deleted.contains(&message.address.occurrence)
&& !message
.address
.references
.iter()
.any(|id| deleted.contains(id))
});
}
}
#[cfg(test)]
mod tests;
+800
View File
@@ -0,0 +1,800 @@
use super::*;
use crate::{
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
ScenarioViewpoint,
};
use std::collections::BTreeMap;
struct Fixture {
project: Project,
owner: Id,
performer: Id,
lane: Id,
repeated_lane: Id,
first: Id,
second: Id,
nested: Id,
fragment: Id,
branch: Id,
view: Id,
other_view: Id,
exchange: Id,
}
fn set_body(element: &mut Element, body: OccurrenceBody) {
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn diagram(owner: Id, alias: &str) -> Diagram {
Diagram {
id: Id::new_v4(),
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
}
}
fn assert_admitted(project: &Project) {
let errors: Vec<_> = validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn has_code(project: &Project, code: &str) -> bool {
validation::validate(project)
.iter()
.any(|issue| issue.code == code)
}
fn layout(diagram: &Diagram) -> &ScenarioLayout {
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
panic!("expected saved payload")
};
layout
}
fn save(project: &mut Project, replacement: Diagram) {
project.diagrams.insert(replacement.id, replacement);
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
project.elements.get_mut(&owner).unwrap().shared_mut()
else {
panic!("Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let performer = project
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
.unwrap();
let receiver = project
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
.unwrap();
let lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
.unwrap();
let repeated_lane = project
.add_element(
Kind::Lifeline,
Layer::Oa,
"Controller review lane",
Some(owner),
)
.unwrap();
let receiver_lane = project
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
.unwrap();
for (id, represented) in [
(lane, performer),
(repeated_lane, performer),
(receiver_lane, receiver),
] {
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let exchange = Id::new_v4();
project.relations.insert(
exchange,
Relation {
id: exchange,
alias: "delivery_channel".into(),
name: "Delivery".into(),
description: String::new(),
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
},
);
let first = project
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
.unwrap();
let second = project
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
.unwrap();
for (id, sender) in [(first, lane), (second, repeated_lane)] {
set_body(
project.elements.get_mut(&id).unwrap(),
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: Some(sender),
receiver: Some(receiver_lane),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
}
let fragment = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Optional delivery",
Some(owner),
)
.unwrap();
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = project
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
.unwrap();
let nested = project
.add_element(
Kind::Occurrence,
Layer::Oa,
"Unfinished nested delivery",
Some(branch),
)
.unwrap();
set_body(
project.elements.get_mut(&nested).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
set_body(
project.elements.get_mut(&branch).unwrap(),
OccurrenceBody::Branch {
guard: "requested".into(),
occurrences: vec![nested],
},
);
set_body(
project.elements.get_mut(&fragment).unwrap(),
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![branch],
},
);
*project
.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap() = vec![first, second, fragment];
let view_record = diagram(owner, "nominal_view");
let other_record = diagram(owner, "review_view");
let view = view_record.id;
let other_view = other_record.id;
save(&mut project, view_record);
save(&mut project, other_record);
assert_admitted(&project);
Fixture {
project,
owner,
performer,
lane,
repeated_lane,
first,
second,
nested,
fragment,
branch,
view,
other_view,
exchange,
}
}
#[test]
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
let mut f = fixture();
let semantics = (f.project.elements.clone(), f.project.relations.clone());
for (view, line, x) in [
(f.view, f.lane, 420.),
(f.view, f.repeated_lane, 680.),
(f.other_view, f.lane, 900.),
] {
let result =
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
save(&mut f.project, result);
}
for (view, id, y) in [
(f.view, f.first, 200.),
(f.view, f.second, 350.),
(f.other_view, f.first, 260.),
] {
let result = set_message(
&f.project,
view,
ScenarioMessagePosition {
address: address(id),
send_y: y,
},
)
.unwrap();
save(&mut f.project, result);
}
assert_eq!(
layout(&f.project.diagrams[&f.view]).lanes,
vec![
ScenarioLanePosition {
lifeline: f.lane,
x: 420.
},
ScenarioLanePosition {
lifeline: f.repeated_lane,
x: 680.
},
]
);
assert_eq!(
layout(&f.project.diagrams[&f.view])
.messages
.iter()
.map(|r| (r.address.occurrence, r.send_y))
.collect::<Vec<_>>(),
vec![(f.first, 200.), (f.second, 350.)]
);
assert_eq!(
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
260.
);
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
assert_eq!(
(f.project.elements.clone(), f.project.relations.clone()),
semantics
);
assert_admitted(&f.project);
}
#[test]
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.nested),
send_y: -80.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
let before = f.project.clone();
let expanded = OccurrenceEndpoint {
references: vec![f.fragment],
occurrence: f.nested,
};
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: expanded.clone(),
send_y: 400.
}
)
.is_err()
);
assert!(remove_message(&f.project, f.view, &expanded).is_err());
assert_eq!(f.project, before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
assert!(has_code(&f.project, "scenario_layout_address"));
}
#[test]
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
let mut f = fixture();
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.performer,
x: 10.
}
)
.is_err()
);
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(id),
send_y: 100.
}
)
.is_err()
);
}
let foreign = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
.unwrap();
let foreign_lane = f
.project
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
.unwrap();
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.first);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 100.
}
)
.is_err()
);
}
#[test]
fn duplicate_positions_reject_even_when_values_match() {
let mut f = fixture();
let lane = ScenarioLanePosition {
lifeline: f.lane,
x: 10.,
};
let message = ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
};
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
entries.lanes = vec![lane.clone(), lane.clone()];
entries.messages = vec![message.clone(), message.clone()];
assert!(has_code(&f.project, "duplicate_scenario_lane"));
assert!(has_code(&f.project, "duplicate_scenario_message"));
assert!(set_lane(&f.project, f.view, lane).is_err());
assert!(set_message(&f.project, f.view, message).is_err());
// Explicit removal can repair all duplicates of the targeted identity.
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
let mut f = fixture();
let absent = f.project.diagrams[&f.view].clone();
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
assert_eq!(
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
absent
);
assert!(
serde_json::to_value(&absent)
.unwrap()
.get("layout")
.is_none()
);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: -0.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 0.,
},
)
.unwrap();
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 0.,
},
)
.unwrap();
assert_eq!(
layout(&result).messages[0].send_y.to_bits(),
(-0.0_f32).to_bits()
);
assert_eq!(
serde_json::to_string(&result).unwrap(),
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
);
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
save(&mut f.project, result);
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
assert_eq!(
result.layout,
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
);
assert_ne!(result, absent);
}
#[test]
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
let mut f = fixture();
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
let before = serde_json::to_string(&f.project).unwrap();
for value in [
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
MAX_COORDINATE + 1.,
] {
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: value
}
)
.is_err()
);
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: value
}
)
.is_err()
);
}
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
assert!(has_code(&f.project, "scenario_layout_coordinate"));
}
#[test]
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
let mut f = fixture();
payload(f.project.diagrams.get_mut(&f.view).unwrap())
.messages
.push(ScenarioMessagePosition {
address: address(f.second),
send_y: f32::INFINITY,
});
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
assert!(has_code(&f.project, "scenario_layout_coordinate"));
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.second),
send_y: 300.,
},
)
.unwrap();
save(&mut f.project, result);
assert_admitted(&f.project);
}
#[test]
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
let mut f = fixture();
let scenario = f.project.diagrams[&f.view].projection;
assert_eq!(scenario.scenario(), Some(f.owner));
assert!(!scenario.is_architecture());
assert!(!scenario.includes(Kind::Lifeline));
assert!(ProjectionKind::Overview.is_architecture());
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
assert!(has_code(&f.project, "scenario_diagram_context"));
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
f.project
.diagrams
.get_mut(&f.view)
.unwrap()
.routes
.push(crate::EdgeRoute {
edge: f.exchange,
waypoints: vec![],
label: None,
});
assert!(has_code(&f.project, "scenario_diagram_content"));
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
payload(f.project.diagrams.get_mut(&f.view).unwrap());
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
assert!(has_code(&f.project, "scenario_layout_projection"));
assert!(
set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 10.
}
)
.is_err()
);
}
#[test]
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
let mut f = fixture();
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
assert_eq!(
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
f.project.diagrams[&f.view]
);
assert_eq!(
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
serde_json::json!(f.first)
);
let mut unknown = encoded.clone();
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
let mut missing = encoded;
missing["layout"]["Scenario"]["messages"][0]["address"]
.as_object_mut()
.unwrap()
.remove("references");
assert!(serde_json::from_value::<Diagram>(missing).is_err());
}
#[test]
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
let mut f = fixture();
let result = set_lane(
&f.project,
f.view,
ScenarioLanePosition {
lifeline: f.lane,
x: 420.,
},
)
.unwrap();
save(&mut f.project, result);
let result = set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: address(f.first),
send_y: 200.,
},
)
.unwrap();
save(&mut f.project, result);
let mut copied = f.project.diagrams[&f.view].clone();
copied.id = f.other_view;
copied.alias = "review_view".into();
copied.name = "Review view".into();
save(&mut f.project, copied.clone());
assert_admitted(&f.project);
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
let mut candidate = f.project.diagrams[&f.view].clone();
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
assert!(layout(&candidate).lanes.is_empty());
assert_eq!(layout(&candidate).messages.len(), 1);
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
assert!(layout(&candidate).messages.is_empty());
assert!(candidate.layout.is_some());
assert_eq!(f.project.diagrams[&f.other_view], copied);
}
#[test]
fn reference_visitor_and_record_count_cover_explicit_rows() {
let f = fixture();
let mut d = f.project.diagrams[&f.view].clone();
assert_eq!(record_count(&d), 0);
let rows = payload(&mut d);
rows.lanes.push(ScenarioLanePosition {
lifeline: f.lane,
x: 50.,
});
rows.messages.push(ScenarioMessagePosition {
address: address(f.first),
send_y: 100.,
});
assert_eq!(record_count(&d), 3);
let replacement = Id::new_v4();
d.layout.as_mut().unwrap().visit_references(|id| {
if *id == f.first {
*id = replacement;
}
});
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
assert_eq!(layout(&d).messages[0].send_y, 100.);
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
}
#[test]
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
let mut f = fixture();
let called = f
.project
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
f.project.elements.get_mut(&called).unwrap().shared_mut()
else {
panic!("Called Scenario fixture body");
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
let called_message = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
.unwrap();
set_body(
f.project.elements.get_mut(&called_message).unwrap(),
OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
*f.project
.elements
.get_mut(&called)
.unwrap()
.steps_mut()
.unwrap() = vec![called_message];
let usage = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
.unwrap();
set_body(
f.project.elements.get_mut(&usage).unwrap(),
OccurrenceBody::ScenarioUse {
scenario: Some(called),
bindings: vec![],
},
);
f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.push(usage);
assert_admitted(&f.project);
for target in [
address(called_message),
address(usage),
OccurrenceEndpoint {
references: vec![usage],
occurrence: called_message,
},
] {
assert!(
set_message(
&f.project,
f.view,
ScenarioMessagePosition {
address: target,
send_y: 200.
}
)
.is_err()
);
}
}
mod source_lifecycle;
@@ -0,0 +1,302 @@
//! Source/command lifecycle checks for the candidate payload. Public version
//! assignment remains the coordinated codec integration gate.
use super::*;
use crate::{Command, Session, language, prepare_command, source_project::*};
fn authored(f: &Fixture) -> CompiledSourceProject {
let mut bundle = format_bundle(&f.project);
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &initial.index.declarations[&f.view];
let source = bundle.files.get_mut(&location.path).unwrap();
let text = format!(
r#"diagram nominal_view "Nominal review" id "{}" {{
projection {{ Scenario {{ scenario @{}; }}; }};
layout {{ Scenario {{
lanes [
// Controller spacing rationale.
{{ lifeline @ // first lane identity
{}; x 4.20e2; }},
// Repeated performer has its own lane.
{{ lifeline @{}; x 680; }}
];
messages [
// First delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
// Second delivery timing rationale.
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
];
}}; }};
}}
"#,
f.view,
f.project.elements[&f.owner].alias,
f.project.elements[&f.lane].alias,
f.project.elements[&f.repeated_lane].alias,
f.project.elements[&f.first].alias,
f.project.elements[&f.second].alias
);
source.replace_range(location.span.start..location.span.end, &text);
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
}
#[test]
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
let f = fixture();
let original = authored(&f);
let mut changed = original.decoded.project.clone();
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
rows.lanes.swap(0, 1);
rows.messages.swap(0, 1);
rows.messages[0].send_y = 390.;
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
assert_eq!(&updated.index.declarations[&f.view].path, path);
let text = &updated.bundle.files[path];
assert!(
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
"{text}"
);
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
assert!(
text.contains(
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
),
"{text}"
);
assert!(text.contains("send_y 2.00e2"), "{text}");
assert!(text.contains("send_y 390"), "{text}");
for (other, bytes) in &original.bundle.files {
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
assert_eq!(&updated.bundle.files[other], bytes);
}
}
assert_eq!(
compile_bundle(&updated.bundle, ProjectLimits::default())
.unwrap()
.decoded
.project,
changed
);
}
#[test]
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
let f = fixture();
let original = authored(&f);
let temporary = Id::new_v4();
let command = Command::Batch(vec![
Command::Reidentify(f.first, temporary),
Command::Reidentify(f.second, f.first),
Command::Reidentify(temporary, f.second),
]);
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
let mut changed = prepared.project;
payload(changed.diagrams.get_mut(&f.view).unwrap())
.messages
.swap(0, 1);
let updated = update_bundle_with_identity_map(
&original,
&changed,
&prepared.identity_map,
ProjectLimits::default(),
)
.unwrap();
assert_eq!(updated.decoded.project, changed);
let path = &original.index.declarations[&f.view].path;
let text = &updated.bundle.files[path];
assert!(
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
"{text}"
);
let first_comment = text.find("// First delivery").unwrap();
assert!(text[first_comment..].contains(&format!(
"occurrence @{}",
changed.elements[&f.second].alias
)));
assert!(text[first_comment..].contains("send_y 2.00e2"));
}
#[test]
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
let f = fixture();
let original = authored(&f);
let mut session = Session::new(original.decoded.project.clone());
session
.execute(Command::Batch(vec![
Command::SetScenarioLanePosition {
diagram: f.view,
position: ScenarioLanePosition {
lifeline: f.lane,
x: 510.,
},
},
Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
},
]))
.unwrap();
let changed = session.project().clone();
assert_eq!(session.revision(), 1);
assert_eq!(changed.elements, original.decoded.project.elements);
assert_eq!(changed.relations, original.decoded.project.relations);
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
session.undo();
assert_eq!(session.project(), &original.decoded.project);
assert!(!session.can_undo());
session.redo();
assert_eq!(session.project(), &changed);
session
.execute(Command::SetScenarioMessagePosition {
diagram: f.view,
position: ScenarioMessagePosition {
address: address(f.second),
send_y: 430.,
},
})
.unwrap();
assert_eq!(session.revision(), 3);
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
assert_eq!(noop.bundle, updated.bundle);
}
#[test]
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
let f = fixture();
let before = language::format(&f.project);
let mut session = Session::new(f.project.clone());
session
.execute(Command::RemoveScenarioLanePosition {
diagram: f.view,
lifeline: f.lane,
})
.unwrap();
assert_eq!(session.project(), &f.project);
assert!(!session.can_undo());
assert_eq!(language::format(session.project()), before);
let mut present = f.project.clone();
present.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let text = language::format(&present);
assert_ne!(text, before);
assert_eq!(language::parse(&text).unwrap(), present);
assert_eq!(language::parse(&before).unwrap(), f.project);
}
#[test]
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
let f = fixture();
let original = authored(&f);
let before = original.bundle.clone();
let path = original.index.declarations[&f.view].path.clone();
for replacement in ["x 1e30", "x not_a_number"] {
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace("x 4.20e2", replacement);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
let mut candidate = before.clone();
let source = candidate.files.get_mut(&path).unwrap();
*source = source.replace(
&format!("occurrence @{}", f.project.elements[&f.first].alias),
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
);
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
assert_eq!(original.bundle, before);
}
#[test]
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
let f = fixture();
let mut bundle = format_bundle(&f.project);
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let location = &compiled.index.declarations[&f.view];
let path = location.path.clone();
bundle.files.get_mut(&path).unwrap().insert_str(
location.span.end - 1,
"\n layout // explicitly automatic\n none;\n",
);
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let mut described = original.decoded.project.clone();
described.diagrams.get_mut(&f.view).unwrap().description =
"Review spacing independently".into();
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
assert_eq!(updated.decoded.project, described);
let mut empty = described.clone();
empty.diagrams.get_mut(&f.view).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
assert_eq!(present.decoded.project, empty);
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
assert_eq!(cleared.decoded.project, described);
assert!(!cleared.bundle.files[&path].contains("layout"));
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
}
#[test]
fn compact_defaults_and_scenario_row_identity_compose_through_rename_and_clear() {
let f = fixture();
let initial = authored(&f);
let mut bundle = initial.bundle.clone();
let location = &initial.index.declarations[&f.performer];
bundle.files.get_mut(&location.path).unwrap().insert_str(
location.span.end - 1,
"\n description // explicitly default prose\n \"\";\n",
);
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let mut expected = original.decoded.project.clone();
let performer = expected.elements.get_mut(&f.performer).unwrap();
performer.name = "Reviewed controller".into();
performer.alias = "reviewed_controller".into();
expected.elements.get_mut(&f.lane).unwrap().alias = "reviewed_lane".into();
let rows = payload(expected.diagrams.get_mut(&f.view).unwrap());
rows.lanes.swap(0, 1);
rows.messages.swap(0, 1);
rows.messages[0].send_y = 390.;
let updated = update_bundle(&original, &expected, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, expected);
let element = &updated.index.declarations[&f.performer];
let text = &updated.bundle.files[&element.path][element.span.start..element.span.end];
assert!(
text.contains("description // explicitly default prose\n \"\";"),
"{text}"
);
assert!(
!text.contains("categories"),
"an omitted default must stay omitted: {text}"
);
let diagram = &updated.index.declarations[&f.view];
let text = &updated.bundle.files[&diagram.path][diagram.span.start..diagram.span.end];
assert!(
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap()
);
assert!(text.contains("reviewed_lane; x 4.20e2;"), "{text}");
assert!(text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap());
assert!(text.contains("send_y 2.00e2"), "{text}");
assert!(!text.contains("view_options"), "{text}");
expected.diagrams.get_mut(&f.view).unwrap().layout = None;
let cleared = update_bundle(&updated, &expected, ProjectLimits::default()).unwrap();
assert_eq!(cleared.decoded.project, expected);
let declaration = &cleared.index.declarations[&f.view];
let text =
&cleared.bundle.files[&declaration.path][declaration.span.start..declaration.span.end];
assert!(!text.contains("layout"), "{text}");
assert!(text.contains("// Controller spacing rationale."), "{text}");
assert_eq!(
update_bundle(&cleared, &expected, ProjectLimits::default())
.unwrap()
.bundle,
cleared.bundle,
);
}
+43 -1
View File
@@ -23,6 +23,22 @@ pub enum Command {
EditRelation(Id, Relation),
CreateDiagram(Diagram),
EditDiagram(Id, Diagram),
SetScenarioLanePosition {
diagram: Id,
position: ScenarioLanePosition,
},
RemoveScenarioLanePosition {
diagram: Id,
lifeline: Id,
},
SetScenarioMessagePosition {
diagram: Id,
position: ScenarioMessagePosition,
},
RemoveScenarioMessagePosition {
diagram: Id,
address: OccurrenceEndpoint,
},
CreateArtifact(ArtifactLink),
EditArtifact(Id, ArtifactLink),
CreateTrace(Trace),
@@ -488,6 +504,26 @@ fn apply_command(
reidentify_tracked(p, *old, value.id, provenance)?;
Some(value.id)
}
Command::SetScenarioLanePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::SetScenarioMessagePosition { diagram, position } => {
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::RemoveScenarioMessagePosition { diagram, address } => {
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
p.diagrams.insert(*diagram, replacement);
Some(*diagram)
}
Command::CreateArtifact(value) => {
vacant(p, value.id)?;
p.artifact_links.insert(value.id, value.clone());
@@ -878,6 +914,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
return Ok(());
}
vacant(p, new)?;
crate::references::check_remap(p, old, new)?;
if p.id == old {
p.id = new;
} else if let Some(mut v) = p.elements.remove(&old) {
@@ -904,12 +941,17 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
}
}
}
crate::references::remap_references(p, old, new);
crate::references::remap_references(p, old, new)?;
Ok(())
}
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
use std::collections::{BTreeMap, BTreeSet};
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
if d.projection.scenario().is_some() {
return Err(ModelError(
"Scenario layout requires the Scenario geometry operation.".into(),
));
}
let parents = crate::projection::parents(p, d);
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
for (id, parent) in &parents {
+109 -7
View File
@@ -972,7 +972,18 @@ pub fn update_bundle_with_identity_map(
};
let local = edits.entry(path.clone()).or_default();
update.header(&actual.header, &previous.header, &next.header, local);
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
let context = if old.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
};
update.value(
&actual.fields,
Some(&previous.fields),
&next.fields,
context,
local,
);
}
}
if old.id != project.id || old.alias != project.alias || old.name != project.name {
@@ -1224,6 +1235,29 @@ impl<'a> InsertionSyntax<'a> {
}
}
// Schema context is explicit: similarly named fields in arbitrary records must
// never acquire Scenario row identity or move their authored trivia.
#[derive(Clone, Copy)]
enum EditContext {
Other,
Diagram,
Layout,
Scenario,
Lanes,
Messages,
}
impl EditContext {
fn field(self, name: &str) -> Self {
match (self, name) {
(Self::Diagram, "layout") => Self::Layout,
(Self::Layout, "Scenario") => Self::Scenario,
(Self::Scenario, "lanes") => Self::Lanes,
(Self::Scenario, "messages") => Self::Messages,
_ => Self::Other,
}
}
}
struct SyntaxUpdate<'a> {
source: &'a str,
before: &'a str,
@@ -1301,6 +1335,7 @@ impl SyntaxUpdate<'_> {
actual: &language::EditValue,
before: Option<&language::EditValue>,
after: &language::EditValue,
context: EditContext,
edits: &mut LocalEdits,
) {
use language::EditKind;
@@ -1316,7 +1351,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.record(actual.span, fields, previous, next, edits);
self.record(actual.span, fields, previous, next, context, edits);
}
(EditKind::List(items), EditKind::List(next)) => {
let previous = before.and_then(|old| {
@@ -1326,7 +1361,7 @@ impl SyntaxUpdate<'_> {
None
}
});
self.list(actual.span, items, previous, next, edits);
self.list(actual.span, items, previous, next, context, edits);
}
(EditKind::Reference(_), EditKind::Reference(alias)) => {
let token = significant(&self.source[actual.span.start..actual.span.end])
@@ -1379,6 +1414,7 @@ impl SyntaxUpdate<'_> {
actual: &BTreeMap<String, language::EditField>,
before: Option<&BTreeMap<String, language::EditField>>,
after: &BTreeMap<String, language::EditField>,
context: EditContext,
edits: &mut LocalEdits,
) {
// A typed constructor tag change retains shared fields and their trivia.
@@ -1402,7 +1438,13 @@ impl SyntaxUpdate<'_> {
},
new_key.clone(),
));
self.value(&field.value, Some(&old.value), &new.value, edits);
self.value(
&field.value,
Some(&old.value),
&new.value,
context.field(new_key),
edits,
);
return;
}
}
@@ -1414,9 +1456,16 @@ impl SyntaxUpdate<'_> {
.and_then(|fields| fields.get(key))
.map(|field| &field.value),
&next.value,
context.field(key),
edits,
);
} else {
} else if !(matches!(context, EditContext::Diagram)
&& key == "layout"
&& before.is_some_and(|fields| !fields.contains_key(key)))
{
// Canonical None omits layout. Preserve an authored explicit
// none while another field changes; a real Some->None clear
// still removes the former payload and retains its trivia.
erase_syntax(self.source, field.span, edits);
}
}
@@ -1470,6 +1519,7 @@ impl SyntaxUpdate<'_> {
actual: &[language::EditValue],
before: Option<&[language::EditValue]>,
after: &[language::EditValue],
context: EditContext,
edits: &mut LocalEdits,
) {
let previous = before.unwrap_or(&[]);
@@ -1482,6 +1532,7 @@ impl SyntaxUpdate<'_> {
self.old_aliases,
self.legacy_value_paths,
None,
context,
)
})
.collect();
@@ -1494,6 +1545,7 @@ impl SyntaxUpdate<'_> {
self.new_aliases,
self.legacy_value_paths,
self.identity_origins,
context,
)
})
.collect();
@@ -1522,7 +1574,7 @@ impl SyntaxUpdate<'_> {
.all(|(i, source)| *source == Some(i))
{
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
self.value(current, previous.get(i), next, edits);
self.value(current, previous.get(i), next, EditContext::Other, edits);
}
return;
}
@@ -1555,7 +1607,13 @@ impl SyntaxUpdate<'_> {
let chunk = chunks[index];
text.push_str(&self.source[chunk.start..current.span.start]);
let mut local = Vec::new();
self.value(current, previous.get(index), next, &mut local);
self.value(
current,
previous.get(index),
next,
EditContext::Other,
&mut local,
);
text.push_str(&changed_text(self.source, current.span, local));
suffix = &self.source[current.span.end..chunk.end];
} else {
@@ -1594,14 +1652,58 @@ impl SyntaxUpdate<'_> {
}
}
fn scenario_item_key(
value: &language::EditValue,
aliases: &BTreeMap<String, String>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
let EditKind::Record(fields) = &value.kind else {
return None;
};
let reference = |value: &language::EditValue| {
if let EditKind::Reference(alias) = &value.kind {
aliases.get(alias).cloned()
} else {
None
}
};
match context {
EditContext::Lanes => {
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
}
EditContext::Messages => {
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
return None;
};
let occurrence = reference(&address.get("occurrence")?.value)?;
let path: Vec<_> = match address.get("references") {
None => Vec::new(),
Some(field) => {
let EditKind::List(path) = &field.value.kind else {
return None;
};
path.iter().map(reference).collect::<Option<Vec<_>>>()?
}
};
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
}
_ => None,
}
}
fn item_key(
value: &language::EditValue,
source: &str,
aliases: &BTreeMap<String, String>,
legacy_value_paths: bool,
origins: Option<&BTreeMap<Id, Id>>,
context: EditContext,
) -> Option<String> {
use language::EditKind;
if matches!(context, EditContext::Lanes | EditContext::Messages) {
return scenario_item_key(value, aliases, context);
}
if matches!(value.kind, EditKind::Record(_))
&& let Some(key) = language::semantic_row_key(
&source[value.span.start..value.span.end],
@@ -72,6 +72,11 @@ pub(super) fn upgrade(
&actual.fields,
Some(&actual.fields),
&next.fields,
if project.diagrams.contains_key(&id) {
EditContext::Diagram
} else {
EditContext::Other
},
edits.entry(path.clone()).or_default(),
);
}
+1
View File
@@ -619,6 +619,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
crate::scenario_layout::validate_diagram(p, d, &mut out);
let option_issues = if d.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
@@ -231,6 +231,7 @@ fn node_project() -> Project {
},
)]),
routes: vec![],
layout: None,
},
);
expected
+1
View File
@@ -41,6 +41,7 @@ fn fixture() -> Fixture {
projection: ProjectionKind::Overview,
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+1 -1
View File
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
assert_eq!(session.project(), &after);
let fresh = Id::new_v4();
let mut reidentified = after.clone();
references::remap_references(&mut reidentified, f.r1, fresh);
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
let mut e = reidentified.elements.remove(&f.r1).unwrap();
e.id = fresh;
reidentified.elements.insert(fresh, e);
+1
View File
@@ -20,6 +20,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
+578 -31
View File
@@ -19,6 +19,10 @@ mod reference_authoring;
#[cfg(test)]
mod review_navigation_tests;
#[cfg(test)]
mod scenario_details_tests;
mod scenario_diagram;
mod scenario_spacing;
#[cfg(test)]
mod source_indentation_tests;
pub(crate) mod source_rename;
mod source_symbols;
@@ -27,6 +31,7 @@ pub(crate) mod text_workspace;
mod transition_context_tests;
#[cfg(test)]
mod view_copy_tests;
pub(crate) const SAVED_SCENARIO_UNAVAILABLE: &str = "This action uses architecture diagram tools and is unavailable for a saved Scenario. Open the view in Behavior; saved positions are preserved.";
use crate::forms::Draft;
use crate::{
behavior,
@@ -546,6 +551,7 @@ pub struct App {
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
source_diagram_job: Option<Id>,
diagram_lookup_in_flight: Option<Id>,
pending_scenario_reveal_visit: Option<Id>,
pending_diagram_reveal: Option<(
crate::diagram_usage::Context,
crate::diagram_usage::Usage,
@@ -594,7 +600,7 @@ impl App {
let active_diagram = project
.diagrams
.values()
.filter(|d| d.layer == layer)
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
.map(|d| d.id);
let draft = Draft::from_project(&project, selected);
@@ -670,6 +676,7 @@ impl App {
source_diagram_job: None,
diagram_lookup_in_flight: None,
pending_diagram_reveal: None,
pending_scenario_reveal_visit: None,
model_issues,
migration: None,
source_version: 0,
@@ -684,6 +691,7 @@ impl App {
workflow: None,
};
app.route_state_diagram_workspace();
app.reconcile_scenario_workspace();
app.last_workspace_page = app.page;
app
}
@@ -819,6 +827,17 @@ impl App {
self.sync_with_alias_editor(None);
}
fn sync_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
// A saved view may be retargeted across layers by one source transaction.
// Follow that explicit identity before generic active-view normalization.
if let Some(rep @ behavior::scenario_layout::Representation::Saved { diagram }) =
self.behavior.scenario.representation()
&& self.active_diagram == Some(diagram)
&& let Some(owner) =
behavior::scenario_layout::presentation::checked_owner(self.project(), rep)
{
self.layer = self.project().elements[&owner].layer();
self.behavior.active_owner = Some(owner);
}
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.model_issues = validate(self.project());
self.normalize_creation();
@@ -843,7 +862,7 @@ impl App {
.project()
.diagrams
.values()
.filter(|d| d.layer == self.layer)
.filter(|d| d.layer == self.layer && d.projection.scenario().is_none())
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
.map(|d| d.id);
}
@@ -923,6 +942,26 @@ impl App {
.map_err(|e| e.to_string())
}) {
Ok(id) => {
if self.behavior.scenario.representation()
== Some(behavior::scenario_layout::Representation::Saved { diagram: original })
&& id != original
{
let representation =
behavior::scenario_layout::Representation::Saved { diagram: id };
if let Some(owner) = behavior::scenario_layout::presentation::checked_owner(
self.project(),
representation,
) {
self.behavior.scenario.enter_representation(
self.canvas_document,
owner,
representation,
);
if self.page != Page::Behavior {
self.behavior.scenario.leave();
}
}
}
if self.active_diagram == Some(original) {
self.active_diagram = Some(id);
}
@@ -1097,7 +1136,7 @@ impl App {
self.project()
.diagrams
.get(id)
.is_some_and(|d| d.layer == layer)
.is_some_and(|d| d.layer == layer && d.projection.scenario().is_none())
})
.or(retained)
.or_else(|| self.diagram_for(layer, target));
@@ -1129,6 +1168,17 @@ impl App {
&& let Some(owner) = crate::behavior::selected_behavior(self.project(), id)
{
self.behavior.active_owner = Some(owner);
if self.behavior.scenario.representation().is_some_and(|rep| {
behavior::scenario_layout::presentation::checked_owner(self.project(), rep)
!= Some(owner)
}) && self.project().elements[&owner].kind() == Kind::Scenario
{
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Derived { owner },
);
} else if self.project().elements[&owner].kind() != Kind::Scenario {
self.behavior.scenario.forget_view();
}
}
if self.reset_state_view_after_navigation(previous_state_view)
&& self.page == Page::Behavior
@@ -1309,6 +1359,9 @@ impl App {
{
return Task::none();
}
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Spacing(event)) = &message {
return self.scenario_spacing(*event);
}
// Reject stale rename intents before camera/reveal reconciliation or
// source interaction can disturb a newer navigation destination.
if let Message::SourceRename(event) = &message
@@ -1321,6 +1374,42 @@ impl App {
if let Message::SourceRename(event @ source_rename::Event::PreviewScrolled(..)) = message {
return self.source_rename_event(event);
}
// Prior view visits are inert before they can retire a newer reveal,
// camera lifetime or focus operation. Passive events may relax epoch,
// but never the destination identity.
if let Message::ScenarioCanvas(event) = &message {
let captured = event.context();
if self.scenario_context().is_none_or(|current| {
current.document != captured.document
|| current.scenario != captured.scenario
|| current.representation != captured.representation
|| current.visit != captured.visit
|| (!matches!(event, behavior::scenario::CanvasEvent::Restored(..))
&& current.revision != captured.revision)
}) {
return Task::none();
}
if let behavior::scenario::CanvasEvent::Camera(_, lifetime, _) = event
&& *lifetime != self.camera_epoch
{
return Task::none();
}
// A retained Details button may belong to an earlier selection or
// canvas epoch. Refuse it before retiring any newer reveal or draft.
if let behavior::scenario::CanvasEvent::Properties(_, id) = event
&& (self.scenario_context() != Some(captured)
|| self.selected != *id
|| !self.project().contains(*id)
|| !behavior::scenario::properties_target(self.project(), captured, *id)
|| self.behavior.has_pending()
|| self.draft_dirty()
|| self.source_dirty()
|| self.source_rename.open()
|| !behavior::canvas_workspace::keyboard_enabled(self))
{
return Task::none();
}
}
let usage_viewport = self.diagram_reveal_viewport(&message);
let window_resized = matches!(&message, Message::WindowResized(_));
// Reject stale measurements before legacy viewport handlers can change
@@ -1346,6 +1435,7 @@ impl App {
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..))
);
let before = self.camera_boundary();
let spacing_selected = self.selected;
let task = self.update_inner(message);
if self.allocation_boundary() != allocation_before {
self.allocation_retire();
@@ -1363,6 +1453,13 @@ impl App {
{
self.diagram_navigation_focus = None;
}
if (self.camera_boundary() != before
|| self.selected != spacing_selected
|| !self.spacing_enabled())
&& self.behavior.scenario.cancel_spacing()
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
if !camera && self.camera_boundary() != before {
self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch;
@@ -1381,6 +1478,7 @@ impl App {
let valid_scenario = |c: behavior::scenario::Context| {
c.document == scope.document
&& c.scenario == scope.owner
&& scope.scenario_view == Some((c.representation, c.visit))
&& c.revision == scope.revision
&& self.camera_toolbar_epoch(c.epoch)
};
@@ -1408,6 +1506,9 @@ impl App {
_ => return Task::none(),
}
let task = self.update(message);
if self.behavior.scenario.cancel_spacing() {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
// Reapplying Fit or a tool also ends a held gesture. Focus is stamped
// after the action, for Iced's final-view ordering.
if self.camera_epoch == scope.epoch {
@@ -1773,6 +1874,7 @@ impl App {
self.refresh_source_rename_context();
self.reconcile_reference_chooser();
self.route_state_diagram_workspace();
self.reconcile_scenario_workspace();
if self.page != Page::Home {
self.last_workspace_page = self.page;
}
@@ -1781,11 +1883,6 @@ impl App {
.topology
.enter(context.document, context.owner);
}
if let Some(context) = self.scenario_context() {
self.behavior
.scenario
.enter(context.document, context.scenario);
}
if self.connection.as_ref().is_some_and(|session| {
Some(session.context) != self.route_context()
|| self.page != Page::Diagram
@@ -1829,15 +1926,23 @@ impl App {
}
}
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
let scenario = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
.then_some(crate::behavior::scenario::Context {
document: self.canvas_document,
scenario,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
if self.page != Page::Behavior {
return None;
}
let scenario = if matches!(
self.behavior.scenario.representation(),
Some(behavior::scenario_layout::Representation::Saved { .. })
) {
self.behavior.scenario.owner()?
} else {
crate::behavior::selected_owner(self)?
};
self.behavior.scenario.context(
self.canvas_document,
scenario,
self.document.revision(),
self.canvas_epoch,
)
}
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
if self.busy
@@ -1853,7 +1958,46 @@ impl App {
}
self.behavior.scenario.focus_stamp(self.scenario_context()?)
}
fn saved_scenario_diagram(&self, id: Id) -> bool {
self.project()
.diagrams
.get(&id)
.is_some_and(|d| d.projection.scenario().is_some())
}
fn unsupported_saved_scenario_message(&self, message: &Message) -> bool {
let active = self
.active_diagram
.is_some_and(|id| self.saved_scenario_diagram(id));
match message {
Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
Message::Projection(projection) => active || projection.scenario().is_some(),
Message::SelectNode(_)
| Message::PlaceSelected
| Message::HideSelected
| Message::ToggleCollapse
| Message::MoveNode(..)
| Message::Route(_)
| Message::Connection(_)
| Message::PortAnchor(_)
| Message::Export
| Message::Fit
| Message::Zoom(_)
| Message::Pan(_) => active,
Message::Add(_) | Message::CreateContextElement(..) => {
active && self.page == Page::Diagram
}
_ => false,
}
}
fn handle(&mut self, message: Message) -> Task<Message> {
// Refuse unsupported saved-view actions before they can Apply unrelated drafts.
// Generic model/source edits and the semantic Scenario workspace remain available.
if self.unsupported_saved_scenario_message(&message) {
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
return Task::none();
}
match message {
Message::WorkDrag(event) => return self.allocation_event(event),
Message::BehaviorControl(intent) => return self.behavior_control(intent),
@@ -1876,6 +2020,8 @@ impl App {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
&& current.revision == context.revision
}) && self.scenario_focus_owner().is_some()
&& self.behavior.scenario.take_form_focus(mount)
@@ -1888,6 +2034,8 @@ impl App {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
&& current.revision == context.revision
}) && self.behavior.scenario.viewport(size)
{
@@ -1897,7 +2045,10 @@ impl App {
}
if let CanvasEvent::Restored(_, token, area, offset) = event {
if self.scenario_context().is_some_and(|current| {
current.document == context.document && current.scenario == context.scenario
current.document == context.document
&& current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
}) && self
.behavior
.scenario
@@ -1914,6 +2065,8 @@ impl App {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.representation == context.representation
&& current.visit == context.visit
&& current.revision == context.revision
}) && self.behavior.scenario.scrolled(area, offset)
{
@@ -1927,6 +2080,8 @@ impl App {
|| !self.scenario_context().is_some_and(|c| {
c.document == context.document
&& c.scenario == context.scenario
&& c.representation == context.representation
&& c.visit == context.visit
&& c.revision == context.revision
})
{
@@ -1935,6 +2090,7 @@ impl App {
let scope = canvas_workspace::Scope {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: lifetime,
};
@@ -1945,6 +2101,9 @@ impl App {
{
return Task::none();
}
if self.behavior.scenario.cancel_spacing() {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
match input {
Event::Traverse(_)
| Event::ControlFocus(_)
@@ -1956,7 +2115,10 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1)
}
input => {
if self.behavior.scenario.camera(input) {
let mut state = std::mem::take(&mut self.behavior.scenario);
let changed = state.camera(self.project(), context.scenario, input);
self.behavior.scenario = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
@@ -1981,10 +2143,56 @@ impl App {
self.begin_model_delete(None);
}
}
CanvasEvent::Representation(_, requested) => {
use behavior::scenario_layout::{
Representation, presentation::checked_owner,
};
if checked_owner(self.project(), requested) != Some(context.scenario) {
return Task::none();
}
// An explicitly selected current view record follows
// the arrangement picker, as with the Diagram picker.
// Independent occurrence/lifeline selection stays put.
let follow_view = matches!(context.representation,
Representation::Saved { diagram } if self.selected == diagram);
if !self.apply() {
return Task::none();
}
let requested = match requested {
Representation::Saved { diagram }
if context.representation
== (Representation::Saved { diagram }) =>
{
Representation::Saved {
diagram: self.active_diagram.unwrap_or(diagram),
}
}
Representation::Derived { owner } => Representation::Derived {
owner: self.behavior.active_owner.unwrap_or(owner),
},
other => other,
};
if self.enter_scenario_representation(requested) && follow_view {
self.selected = match requested {
Representation::Saved { diagram } => diagram,
// Automatic has no Diagram record; its Scenario
// owner is the corresponding inspectable target.
Representation::Derived { owner } => owner,
};
self.refresh_draft();
}
}
CanvasEvent::Shelf(_, shelf) => {
self.behavior.scenario.toggle_shelf(shelf);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Properties(_, _) => {
// The checked action opens the current record only: no
// select()/Apply side effect or representation change.
self.behavior.scenario.show_properties();
self.inspector_tab = crate::navigation::InspectorTab::Properties;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, scale) => {
let mut state = std::mem::take(&mut self.behavior.scenario);
if state.camera_zoom(self.project(), context.scenario, scale) {
@@ -2005,7 +2213,11 @@ impl App {
Ok(target) => {
// A command can preselect this target while the
// caller remains visible, bypassing select below.
self.behavior.active_owner = Some(target);
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Derived {
owner: target,
},
);
self.select(target);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
@@ -2027,7 +2239,7 @@ impl App {
// A command may already have selected this UUID while
// leaving the referenced definition open. select's
// same-UUID fast path must not keep that old owner.
self.behavior.active_owner = Some(caller.owner);
self.enter_scenario_representation(caller.representation);
self.select(caller.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(context) = self.scenario_context() {
@@ -2055,6 +2267,15 @@ impl App {
}
}
CanvasEvent::Choose(_, tool) => {
if tool != scenario::CanvasTool::Select
&& !self
.behavior
.scenario
.coordinate_input(self.project(), context)
{
self.status = "Canvas insertion and reordering are unavailable for this presentation.".into();
return Task::none();
}
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
@@ -2066,6 +2287,17 @@ impl App {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
mutation => {
if !behavior::scenario_layout::presentation::present(
self.project(),
context.representation,
self.selected,
)
.capabilities()
.coordinate_input
{
self.status = "Layout editing is unavailable for this presentation. Saved positions are preserved.".into();
return Task::none();
}
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
@@ -2166,6 +2398,13 @@ impl App {
})
{
self.behavior.active_owner = Some(choice.id);
if self.project().elements[&choice.id].kind() == Kind::Scenario {
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Derived { owner: choice.id },
);
} else {
self.behavior.scenario.forget_view();
}
self.behavior.diagram = Default::default();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(choice.id);
@@ -2514,7 +2753,7 @@ impl App {
self.project()
.diagrams
.get(&id)
.is_some_and(|d| d.projection.state_machine().is_some())
.is_some_and(|d| !d.projection.is_architecture())
})
{
// Architecture remains reachable from a state workspace.
@@ -2523,9 +2762,7 @@ impl App {
.project()
.diagrams
.values()
.filter(|d| {
d.layer == self.layer && d.projection.state_machine().is_none()
})
.filter(|d| d.layer == self.layer && d.projection.is_architecture())
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
.map(|d| d.id);
self.fit();
@@ -2536,6 +2773,15 @@ impl App {
crate::behavior::selected_behavior(self.project(), self.selected)
{
self.behavior.active_owner = Some(owner);
if self.project().elements[&owner].kind() == Kind::Scenario
&& self.behavior.scenario.owner() != Some(owner)
{
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Derived { owner },
);
} else if self.project().elements[&owner].kind() != Kind::Scenario {
self.behavior.scenario.forget_view();
}
}
self.behavior.active_owner = crate::behavior::selected_owner(self);
self.reset_state_view_after_navigation(previous_state_view);
@@ -2772,6 +3018,7 @@ impl App {
}
Message::Layer(layer) => {
if self.apply() {
self.behavior.scenario.forget_view();
let previous_state_view = self.state_view_identity();
self.layer = layer;
self.create_kind = if layer == Layer::Oa {
@@ -2783,7 +3030,7 @@ impl App {
.project()
.diagrams
.values()
.filter(|d| d.layer == layer)
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
.map(|d| d.id);
self.selected = self.project().id;
@@ -2944,6 +3191,9 @@ impl App {
}
}
Message::RevealInDiagram(diagram, selected) => {
if self.saved_scenario_diagram(diagram) {
return self.reveal_saved_scenario(diagram, selected);
}
let original = self.selected;
if !self.apply() {
return Task::none();
@@ -2956,6 +3206,10 @@ impl App {
let Some(view) = self.project().diagrams.get(&diagram) else {
return Task::none();
};
if view.projection.scenario().is_some() {
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
return Task::none();
}
let layer = view.layer;
let endpoints = self
.project()
@@ -3023,6 +3277,7 @@ impl App {
description: String::new(),
layer: self.layer,
projection: ProjectionKind::Allocation,
layout: None,
routes: Vec::new(),
view_options: Default::default(),
nodes: Default::default(),
@@ -3176,6 +3431,16 @@ impl App {
self.status = "Diagram no longer exists.".into();
return Task::none();
}
if self.saved_scenario_diagram(choice.id)
&& behavior::scenario_layout::presentation::checked_owner(
self.project(),
behavior::scenario_layout::Representation::Saved { diagram: choice.id },
)
.is_none()
{
self.status = "This Scenario view has an incompatible or missing owner.".into();
return Task::none();
}
let previous_diagram = self.active_diagram;
let follow_view = previous_diagram == Some(self.selected);
if self.apply() {
@@ -3195,7 +3460,14 @@ impl App {
}
self.selected_node = None;
self.refresh_draft();
self.fit();
if self.saved_scenario_diagram(diagram) {
self.enter_scenario_representation(
behavior::scenario_layout::Representation::Saved { diagram },
);
} else {
self.page = Page::Diagram;
self.fit();
}
}
}
Message::Projection(projection) => {
@@ -3205,6 +3477,10 @@ impl App {
.and_then(|id| self.project().diagrams.get(&id))
.cloned()
{
if d.projection.scenario().is_some() {
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
return Task::none();
}
d.projection = projection;
self.execute(Command::EditDiagram(d.id, d));
self.fit();
@@ -3789,6 +4065,14 @@ impl App {
}
}
Message::Export => {
let Some(diagram) = self.active_diagram else {
self.status = "Create a diagram first.".into();
return Task::none();
};
if !self.project().diagrams.contains_key(&diagram) {
self.status = crate::diagram::ExportError::MissingDiagram(diagram).to_string();
return Task::none();
}
if self.apply() {
return self.choose_file(FileOperation::Export);
}
@@ -3851,7 +4135,9 @@ impl App {
};
let id = e.id;
let mut commands = vec![Command::CreateElement(e)];
let mut diagram_target = self.active_diagram;
let mut diagram_target = self
.active_diagram
.filter(|id| !self.saved_scenario_diagram(*id));
let preferred = if self.create_kind.is_work() || self.create_kind.is_performer() {
Some(ProjectionKind::Allocation)
} else if ProjectionKind::Capabilities.includes(self.create_kind) {
@@ -3881,6 +4167,7 @@ impl App {
description: String::new(),
layer: self.layer,
projection: preferred,
layout: None,
routes: Vec::new(),
view_options: Default::default(),
nodes: Default::default(),
@@ -4180,11 +4467,24 @@ impl App {
)
}
fn export_to(&mut self, path: PathBuf) -> Task<Message> {
if self
.active_diagram
.is_some_and(|id| self.saved_scenario_diagram(id))
{
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
return Task::none();
}
let Some(diagram) = self.active_diagram else {
self.status = "Create a diagram first.".into();
return Task::none();
};
let svg = crate::diagram::export_svg(self.project(), diagram);
let svg = match crate::diagram::export_svg(self.project(), diagram) {
Ok(svg) => svg,
Err(error) => {
self.status = error.to_string();
return Task::none();
}
};
self.busy = true;
Task::perform(
async move {
@@ -4423,6 +4723,9 @@ impl App {
)
}
pub fn route_context(&self) -> Option<crate::route_edit::Context> {
if self.saved_scenario_diagram(self.active_diagram?) {
return None;
}
Some(crate::route_edit::Context {
document: self.canvas_document,
diagram: self.active_diagram?,
@@ -4647,6 +4950,12 @@ impl App {
}
}
pub fn fit(&mut self) {
if self
.active_diagram
.is_some_and(|id| self.saved_scenario_diagram(id))
{
return;
}
self.fit_to_view = true;
let view = self
.active_diagram
@@ -4694,7 +5003,7 @@ fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id)
return p
.diagrams
.values()
.filter(|d| d.layer == layer)
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
.min_by_key(|d| {
let view = project_view(p, d.id);
(
@@ -4708,7 +5017,7 @@ fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id)
}
p.diagrams
.values()
.filter(|d| d.layer == layer)
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
.min_by_key(|d| {
(
!p.elements
@@ -5789,6 +6098,7 @@ mod port_view_tests {
description: String::new(),
layer: Layer::Sa,
projection: ProjectionKind::Capabilities,
layout: None,
routes: Vec::new(),
view_options: Default::default(),
nodes: Default::default(),
@@ -5955,3 +6265,240 @@ mod data_paging_lifecycle_tests {
assert_eq!(app.data_state, crate::data_view::State::default());
}
}
#[cfg(test)]
mod saved_scenario_dispatch_tests {
use super::*;
fn fixture() -> (App, Id, Id, Id) {
let f = behavior::scenario::direct_tests::fixture();
let mut project = f.project;
let architecture = project.ensure_diagram(Layer::Oa);
let saved = Id::new_v4();
project.diagrams.insert(
saved,
Diagram {
id: saved,
alias: "saved_scenario_dispatch".into(),
name: "Saved Scenario".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: f.owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: None,
},
);
let mut app = App::with_project(project);
app.page = Page::Diagram;
app.active_diagram = Some(architecture);
app.select(f.owner);
(app, architecture, saved, f.owner)
}
#[test]
fn rejected_export_schedules_no_write_and_preserves_draft_history_and_busy_state() {
let (mut app, _, saved, _) = fixture();
let _ = app.update(Message::Field("/name".into(), "Keep export draft".into()));
assert!(app.draft_dirty());
let path =
std::env::temp_dir().join(format!("arcadia-rejected-export-{}.svg", Id::new_v4()));
std::fs::write(&path, b"existing export").unwrap();
for target in [Some(saved), Some(Id::new_v4()), None] {
for busy in [false, true] {
app.active_diagram = target;
app.busy = busy;
let before = app.document.bundle().clone();
let history = (
app.document.revision(),
app.document.can_undo(),
app.document.can_redo(),
);
let view = (
app.page,
app.selected,
app.active_diagram,
app.zoom,
app.pan,
);
let task = app.export_to(path.clone());
assert_eq!(task.units(), 0, "rejection must not schedule a write");
assert_eq!(app.busy, busy);
assert_eq!(app.document.bundle(), &before);
assert_eq!(
(
app.document.revision(),
app.document.can_undo(),
app.document.can_redo()
),
history
);
assert_eq!(
(
app.page,
app.selected,
app.active_diagram,
app.zoom,
app.pan
),
view
);
assert!(app.draft_dirty());
assert!(app.file_prompt.is_none());
assert_eq!(std::fs::read(&path).unwrap(), b"existing export");
}
}
std::fs::remove_file(path).unwrap();
}
#[test]
fn missing_export_target_does_not_apply_pending_properties_or_open_dialog() {
for target in [None, Some(Id::new_v4())] {
let (mut app, _, _, _) = fixture();
let _ = app.update(Message::Field(
"/name".into(),
"Unapplied export draft".into(),
));
app.active_diagram = target;
let before = app.document.bundle().clone();
let revision = app.document.revision();
let task = app.handle(Message::Export);
assert_eq!(task.units(), 0);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.document.revision(), revision);
assert!(app.draft_dirty());
assert!(app.file_prompt.is_none());
assert!(!app.busy);
}
}
#[test]
fn saved_picker_with_invalid_properties_and_reveal_with_pending_properties_do_not_apply() {
for reveal in [false, true] {
let (mut app, architecture, saved, owner) = fixture();
let _ = app.update(Message::Field(
if reveal { "/name" } else { "/id" }.into(),
if reveal {
"Unapplied scenario name"
} else {
"-"
}
.into(),
));
assert!(app.draft_dirty());
let before = app.document.bundle().clone();
let revision = app.document.revision();
let selected = app.selected;
let message = if reveal {
Message::RevealInDiagram(saved, owner)
} else {
Message::Diagram(Choice {
id: saved,
label: "Saved Scenario".into(),
})
};
let _ = app.update(message);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.document.revision(), revision);
assert_eq!(app.active_diagram, Some(architecture));
assert_eq!(app.selected, selected);
assert!(app.draft_dirty());
}
}
#[test]
fn saved_scenario_cannot_use_architecture_controls_or_change_projection_by_toolbar() {
let (mut app, _, saved, _) = fixture();
app.active_diagram = Some(saved);
let _ = app.update(Message::Field("/name".into(), "Keep pending".into()));
let before = app.document.bundle().clone();
assert!(app.route_context().is_none());
assert!(architecture_authoring::kinds(&app).is_empty());
for message in [
Message::Projection(ProjectionKind::Overview),
Message::PlaceSelected,
Message::Command(Command::Layout(saved)),
Message::Export,
] {
let _ = app.update(message);
assert_eq!(app.document.bundle(), &before);
assert!(app.draft_dirty());
assert!(app.file_prompt.is_none());
assert!(
app.project().diagrams[&saved]
.projection
.scenario()
.is_some()
);
}
}
#[test]
fn saved_scenario_does_not_start_the_existing_architecture_palette() {
let (mut app, _, saved, _) = fixture();
app.active_diagram = Some(saved);
let before = app.document.bundle().clone();
let _ = app.update(Message::Architecture(
architecture_authoring::Event::Existing(None),
));
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
assert!(app.status.contains("architecture view"));
}
#[test]
fn forged_saved_usage_is_refused_without_selection_or_camera_changes() {
let (mut app, architecture, saved, _) = fixture();
let before = (
app.page,
app.selected,
app.layer,
app.zoom,
app.pan,
app.canvas_epoch,
);
let source = app.document.bundle().clone();
let usage = crate::diagram_usage::Usage {
subject: saved,
surface: crate::diagram_usage::Surface::Saved(saved),
target: crate::diagram_usage::Target::Whole,
role: "diagram".into(),
bounds: iced::Rectangle::new(iced::Point::new(500., 600.), iced::Size::new(300., 200.)),
};
assert!(!app.reveal_diagram_usage(&usage));
assert_eq!(
(
app.page,
app.selected,
app.layer,
app.zoom,
app.pan,
app.canvas_epoch
),
before
);
assert_eq!(app.active_diagram, Some(architecture));
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(app.document.bundle(), &source);
}
#[test]
fn semantic_scenario_preview_remains_available_and_saved_views_are_not_default_architecture() {
let (mut app, architecture, saved, owner) = fixture();
assert_eq!(
preferred_diagram(app.project(), Some(saved), Layer::Oa, owner),
Some(architecture)
);
app.active_diagram = Some(saved);
let before = app.document.bundle().clone();
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.scenario_context().unwrap().scenario, owner);
assert_eq!(app.document.bundle(), &before);
let mut only_saved = app.project().clone();
only_saved.diagrams.retain(|id, _| *id == saved);
assert!(preferred_diagram(&only_saved, None, Layer::Oa, owner).is_none());
assert!(App::with_project(only_saved).active_diagram.is_none());
}
}
@@ -138,7 +138,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
.diagrams
.get(&s.diagram)
.ok_or("The creation draft's diagram is missing.")?;
if matches!(d.projection, ProjectionKind::StateMachine { .. })
if !d.projection.is_architecture()
|| s.query.len() > 16384
|| s.page > 1_000_000
|| s.point
@@ -180,7 +180,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
}
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
let mut values = match (d.layer, d.projection) {
(_, ProjectionKind::StateMachine { .. }) => vec![],
(_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![],
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
(Layer::Oa, ProjectionKind::Capabilities) => vec![
@@ -208,11 +208,12 @@ pub fn kinds(app: &App) -> Vec<Kind> {
.unwrap_or_default()
}
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
d.projection.is_architecture()
&& p.elements.get(&id).is_some_and(|e| {
e.kind() != Kind::Port
&& d.projection.includes(e.kind())
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
})
}
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
// Existing System remains a useful choice even when the singleton exists.
@@ -257,6 +258,17 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
}
impl App {
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
if matches!(
&event,
Event::Start(..) | Event::Allocate(..) | Event::Existing(..)
) && !self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| d.projection.is_architecture())
{
self.status = "Choose an architecture view before adding architecture content.".into();
return Task::none();
}
match event {
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
if self.architecture.is_some() {
+17 -2
View File
@@ -1,5 +1,6 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::behavior::scenario_layout::Representation;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
@@ -47,11 +48,23 @@ impl App {
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
Surface::Scenario(c.scenario),
match c.representation {
Representation::Derived { owner } => Surface::Scenario(owner),
Representation::Saved { diagram } => Surface::Saved(diagram),
},
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
&& c.epoch == *epoch
&& self.pending_scenario_reveal_visit == Some(c.visit)
&& self.page == Page::Behavior
&& self.behavior.active_owner == Some(c.scenario)
&& self.behavior.scenario.context(
self.canvas_document,
c.scenario,
self.document.revision(),
self.canvas_epoch,
) == Some(*c),
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
@@ -93,6 +106,7 @@ impl App {
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) {
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
@@ -142,5 +156,6 @@ impl App {
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
}
}
@@ -98,6 +98,7 @@ fn fixture(canvas: Canvas) -> (App, Usage) {
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
view_options: Default::default(),
},
@@ -143,6 +144,7 @@ fn measurement(
epoch: u64,
surface: Surface,
size: Size,
visit: Id,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
@@ -159,6 +161,10 @@ fn measurement(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
representation: Representation::Derived {
owner: surface.id(),
},
visit,
revision: context.revision,
epoch,
},
@@ -176,12 +182,16 @@ fn measurement(
}
}
fn stamp(app: &App) -> (Context, u64) {
fn stamp(app: &App) -> (Context, u64, Id) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(*context, *epoch)
(
*context,
*epoch,
app.pending_scenario_reveal_visit.unwrap_or(Id::nil()),
)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
@@ -219,7 +229,11 @@ fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
expected.camera(
app.project(),
target.surface.id(),
behavior::canvas_workspace::Event::Pan(pan),
);
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
@@ -238,10 +252,10 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let (context, epoch, visit) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
@@ -260,7 +274,7 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
@@ -274,7 +288,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let (context, epoch, visit) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
@@ -289,13 +303,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
}
send(
&mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size),
measurement(canvas, stale, wrong_epoch, target.surface, size, visit),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch));
assert_eq!(stamp(&app), (context, epoch, visit));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
@@ -303,7 +317,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
@@ -317,9 +331,10 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch) = stamp(&app);
let (context, old_epoch, visit) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch) = stamp(&app);
let (current, epoch, current_visit) = stamp(&app);
assert_eq!(current_visit, visit);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
@@ -327,14 +342,14 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit),
);
assert_eq!(stamp(&app), (current, epoch));
assert_eq!(stamp(&app), (current, epoch, visit));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL),
measurement(canvas, current, epoch, target.surface, ACTUAL, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
@@ -349,7 +364,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let (context, epoch, visit) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
@@ -386,7 +401,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE),
measurement(canvas, context, epoch, target.surface, WIDE, visit),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
+99 -10
View File
@@ -1,5 +1,6 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)]
mod tests;
@@ -181,6 +182,7 @@ impl App {
// Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None;
self.pending_scenario_reveal_visit = None;
self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([
@@ -291,11 +293,78 @@ impl App {
Task::none()
}
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
/// never selects the owner or another saved arrangement as a fallback.
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<Message> {
if self.diagram_usages.is_some() || !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into();
return Task::none();
}
if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none()
{
return Task::none();
}
let Some(found) = self
.current_surface_usages(selected, Surface::Saved(diagram))
.into_iter()
.find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_)))
else {
self.status = "This object has no visible target in that saved Scenario.".into();
return Task::none();
};
self.reveal_diagram_usage(&found);
Task::none()
}
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
if !self.usage_navigation_available() {
return false;
}
let previous_layer = self.layer;
let scenario_representation = match usage.surface {
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
Some(Representation::Saved { diagram })
}
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
_ => None,
};
if let Some(representation) = scenario_representation {
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
return false;
};
if !self
.behavior
.scenario
.representation_usages(self.project(), representation, usage.subject)
.iter()
.any(|row| row == usage)
{
return false;
}
// Finish target/camera admission before changing the App route. The
// checked immutable owner also makes the following entry infallible.
let revealed = match representation {
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
self.document.project(),
self.canvas_document,
owner,
&usage.target,
),
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
self.document.project(),
self.canvas_document,
owner,
representation,
&usage.target,
),
};
if !revealed {
return false;
}
if !self.enter_scenario_representation(representation) {
return false;
}
}
let p = self.document.project();
let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None),
@@ -335,7 +404,10 @@ impl App {
};
self.layer = diagram.layer;
self.active_diagram = Some(id);
if let Some(machine) = diagram.projection.state_machine() {
if let Some(owner) = diagram.projection.scenario() {
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
} else if let Some(machine) = diagram.projection.state_machine() {
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
@@ -377,19 +449,14 @@ impl App {
self.page = Page::Behavior;
}
Surface::Scenario(owner) => {
if !self.behavior.scenario.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
}
if scenario_representation.is_none() {
self.behavior.scenario.forget_view();
}
self.selected = selected;
self.selected_node = placement;
self.model_menu = None;
@@ -416,6 +483,18 @@ impl App {
usage: usage.clone(),
selected,
});
self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| {
let owner = presentation::checked_owner(self.project(), representation)?;
self.behavior
.scenario
.context(
self.canvas_document,
owner,
self.document.revision(),
self.canvas_epoch,
)
.map(|context| context.visit)
});
self.pending_diagram_reveal = Some((
Context {
document: self.canvas_document,
@@ -436,10 +515,20 @@ impl App {
Surface::Saved(id) => {
self.active_diagram == Some(id)
&& matches!(self.page, Page::Diagram | Page::Behavior)
&& (!self.saved_scenario_diagram(id)
|| (self.page == Page::Behavior
&& self.behavior.scenario.representation()
== Some(Representation::Saved { diagram: id })))
}
Surface::Process(owner) | Surface::Scenario(owner) => {
Surface::Process(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
Surface::Scenario(owner) => {
self.page == Page::Behavior
&& self.behavior.active_owner == Some(owner)
&& self.behavior.scenario.representation()
== Some(Representation::Derived { owner })
}
};
(current_surface
&& focus.usage.surface == surface
@@ -1400,3 +1400,5 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() {
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
}
mod saved_scenario;
@@ -0,0 +1,108 @@
arcadia 7;
project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" {
// Two operational activities, with one lifeline appearance per Scenario.
oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {}
oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {}
interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" {
source @report_incident;
target @assess_incident;
}
oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" {
viewpoint OperationalActivity;
steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review];
}
oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" {
parent @incident_response; represented @report_incident;
}
oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" {
parent @incident_response; represented @assess_incident;
}
// Both appearances reuse one definition. Navigation must retain the occurrence.
oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
}
// A correlated self call/reply with deliberately unassigned exchange definitions.
oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; };
}
oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" {
parent @incident_response;
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; };
}
// Intentional unfinished work: no receiver and therefore no fabricated arrow.
oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" {
parent @incident_response;
body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; };
}
oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" {
parent @incident_response;
body { ScenarioUse {
bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }];
scenario @reporting_review;
}; };
}
// This definition stays opaque in the caller. Open it, then Back to the saved view.
oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" {
viewpoint OperationalActivity;
steps [@review_delivery];
}
oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" {
parent @reporting_review; represented @report_incident;
}
oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" {
parent @reporting_review; represented @assess_incident;
}
oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" {
parent @reporting_review;
body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; };
}
diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }];
messages [
{ address { references []; occurrence @initial_report; }; send_y 160; },
{ address { references []; occurrence @updated_report; }; send_y 252; },
{ address { references []; occurrence @assess_call; }; send_y 344; },
{ address { references []; occurrence @assess_reply; }; send_y 436; }
];
}; };
}
diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" {
layer Oa;
projection { Scenario { scenario @incident_response; }; };
layout { Scenario {
// These valid authored lanes must remain saved during complete fallback.
lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }];
messages [
// REPAIR: change only this 300 to 424, then Undo and Redo the edit.
{ address { references []; occurrence @assess_reply; }; send_y 300; },
// This authored value stays latent while the receiver is missing.
{ address { references []; occurrence @unfinished_notice; }; send_y 2000; }
];
}; };
}
}
@@ -0,0 +1,362 @@
//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history.
use super::*;
use crate::behavior::scenario::{CanvasEvent, State};
use crate::behavior::scenario_layout::{
Representation,
presentation::{self, PresentationStatus},
};
use iced::{Size, Vector};
fn fixture() -> App {
let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap();
let errors: Vec<_> = oa_core::validation::validate(&p)
.into_iter()
.filter(|d| d.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
App::with_project(p)
}
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn representation(view: Id) -> Representation {
Representation::Saved { diagram: view }
}
fn find(app: &App, subject: Id, view: Id) -> Usage {
usage::saved_usages_for(app.project(), subject, Some(view))
.into_iter()
.find(|row| row.target == Target::ScenarioObject(subject))
.expect("exact saved occurrence")
}
fn context(app: &App) -> behavior::scenario::Context {
let owner = app.behavior.active_owner.unwrap();
app.behavior
.scenario
.context(
app.canvas_document,
owner,
app.document.revision(),
app.canvas_epoch,
)
.unwrap()
}
fn navigate(app: &mut App, subject: Id, view: Id) -> Id {
let (token, rows) = open(app, subject);
let found = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject))
.expect("saved chooser destination");
choose(app, token, found);
assert!(app.diagram_usages.is_none());
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.active_diagram, Some(view));
assert_eq!(
app.behavior.scenario.representation(),
Some(representation(view))
);
assert_eq!(app.selected, subject);
token
}
#[test]
fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque()
{
let app = fixture();
let owner = id(&app, "incident_response");
let first = id(&app, "initial_report");
let second = id(&app, "updated_report");
let exchange = id(&app, "incident_report");
let latent = id(&app, "unfinished_notice");
let callee_message = id(&app, "review_delivery");
let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a));
let derived = app
.behavior
.scenario
.diagram_usages(app.project(), owner, first)
.pop()
.unwrap();
let rows = views.map(|view| find(&app, first, view));
assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.);
assert_ne!(rows[0].bounds, rows[1].bounds);
assert_eq!(
rows[2].bounds, derived.bounds,
"complete fallback cannot retain the authored lane override"
);
for view in views {
let represented = usage::saved_usages_for(app.project(), exchange, Some(view));
assert_eq!(represented.len(), 2);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(first))
);
assert!(
represented
.iter()
.any(|u| u.target == Target::ScenarioObject(second))
);
assert!(
represented
.iter()
.all(|u| u.surface == Surface::Saved(view))
);
assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty());
assert_eq!(
find(&app, latent, view).target,
Target::ScenarioObject(latent)
);
let shown = presentation::present(app.project(), representation(view), owner);
let whole = usage::saved_usages_for(app.project(), view, Some(view));
assert_eq!(whole.len(), 1);
assert_eq!(whole[0].target, Target::Whole);
assert_eq!(Some(whole[0].bounds), shown.bounds());
}
let wide = presentation::present(app.project(), representation(views[1]), owner)
.bounds()
.unwrap();
assert!(wide.x < -500. && wide.x + wide.width > 50000.);
let fallback = presentation::present(app.project(), representation(views[2]), owner);
assert!(matches!(
fallback.status(),
PresentationStatus::Fallback { .. }
));
assert_eq!(fallback.latent_messages(), &[latent]);
assert!(!fallback.capabilities().coordinate_input);
}
#[test]
fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() {
for alias in ["incident_readable", "incident_wide", "incident_repair"] {
let mut app = fixture();
let view = id(&app, alias);
let first = id(&app, "initial_report");
let owner = id(&app, "incident_response");
let original = app.document.snapshot();
app.document
.execute(Command::Rename(first, "Reviewed incident report".into()))
.unwrap();
let edited = app.document.snapshot();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
navigate(&mut app, first, view);
assert_eq!(app.behavior.active_owner, Some(owner));
assert_eq!(authored(&app), before);
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(app.active_diagram, Some(view));
assert_eq!(authored(&app), before);
assert!(app.document.undo());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(app.document.redo());
assert_eq!(app.project(), &edited.decoded.project);
assert_eq!(app.document.bundle(), &edited.bundle);
}
}
#[test]
fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() {
for chooser in [false, true] {
for block in 0..3 {
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let choice = chooser.then(|| {
let (token, rows) = open(&mut app, first);
(
token,
rows.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap(),
)
});
match block {
0 => {
let cursor = app.source_editor.cursor();
app.source_workspace
.edit("? preserve unfinished source".into(), cursor);
}
1 => {
app.draft
.as_mut()
.unwrap()
.input("/name", "Unapplied inspector value".into());
assert!(app.draft_dirty());
}
_ => {
let p = app.project().clone();
app.behavior
.creation
.update(&p, behavior::creation::Event::Open(Layer::Oa))
.unwrap();
assert!(app.behavior.has_pending());
}
}
let before = authored(&app);
let prior = location(&app);
let draft = app
.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
if let Some((token, row)) = choice {
choose(&mut app, token, row);
} else {
send(&mut app, Message::RevealInDiagram(view, first));
}
assert_eq!(authored(&app), before);
assert_eq!(location(&app), prior);
assert_eq!(
app.draft
.as_ref()
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
draft
);
}
}
}
#[test]
fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() {
let mut app = fixture();
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let first = id(&app, "initial_report");
let before = authored(&app);
let old_token = navigate(&mut app, first, a);
let old = context(&app);
navigate(&mut app, first, b);
navigate(&mut app, first, a);
let current = context(&app);
assert_ne!(old.visit, current.visit);
let held = behavior::scenario::Context {
epoch: current.epoch,
..old
};
let camera = app.behavior.scenario.camera_boundary();
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))),
);
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Shelf(
held,
crate::behavior::scenario::Shelf::Selection,
)),
);
send(
&mut app,
Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())),
);
send(&mut app, Message::DiagramUsage(Event::Close(old_token)));
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
assert!(app.pending_diagram_reveal.is_some());
assert!(app.behavior.scenario.camera_boundary() == camera);
let bounds = find(&app, first, a).bounds;
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(current, Size::new(500., 300.))),
);
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(context(&app).visit, current.visit);
let mut expected = State::default();
expected.enter_representation(app.canvas_document, current.scenario, representation(a));
expected.viewport(Size::new(500., 300.));
expected.zoom(Some(1.25));
expected.camera(
app.project(),
current.scenario,
behavior::canvas_workspace::Event::Pan(Vector::new(
250. - bounds.center_x() * 1.25,
150. - bounds.center_y() * 1.25,
)),
);
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
assert_eq!(authored(&app), before);
}
#[test]
fn retargeted_saved_view_cannot_reveal_a_former_owner_occurrence() {
let mut app = fixture();
let first = id(&app, "initial_report");
let view = id(&app, "incident_readable");
let other = id(&app, "reporting_review");
let (token, rows) = open(&mut app, first);
let old = rows
.into_iter()
.find(|u| u.surface == Surface::Saved(view))
.unwrap();
let mut replacement = app.project().diagrams[&view].clone();
replacement.projection = ProjectionKind::Scenario { scenario: other };
replacement.layout = None;
app.document
.execute(Command::EditDiagram(view, replacement))
.unwrap();
app.sync_source();
app.refresh_draft();
let before = authored(&app);
let prior = location(&app);
choose(&mut app, token, old);
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
finish_lookup(&mut app);
let token = app.diagram_usages.as_ref().unwrap().token;
assert!(
app.diagram_usages
.as_ref()
.unwrap()
.usages
.iter()
.all(|u| u.surface != Surface::Saved(view))
);
send(&mut app, Message::DiagramUsage(Event::Close(token)));
send(&mut app, Message::RevealInDiagram(view, first));
assert_eq!(location(&app), prior);
assert_eq!(authored(&app), before);
}
#[test]
fn explicit_saved_layout_repair_undo_redo_requeries_authored_and_fallback_geometry_without_extra_history()
{
let mut app = fixture();
let view = id(&app, "incident_repair");
let first = id(&app, "initial_report");
let reply = id(&app, "assess_reply");
let before = app.document.snapshot();
let fallback = find(&app, first, view);
app.document
.execute(Command::SetScenarioMessagePosition {
diagram: view,
position: oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence: reply,
},
send_y: 424.,
},
})
.unwrap();
app.sync_source();
app.refresh_draft();
let repaired = app.document.snapshot();
let authored_row = find(&app, first, view);
assert_ne!(authored_row.bounds, fallback.bounds);
navigate(&mut app, first, view);
assert!(app.document.undo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(find(&app, first, view).bounds, fallback.bounds);
assert!(app.document.redo());
app.sync_source();
app.refresh_draft();
assert_eq!(app.project(), &repaired.decoded.project);
assert_eq!(app.document.bundle(), &repaired.bundle);
assert_eq!(find(&app, first, view).bounds, authored_row.bounds);
}
@@ -65,6 +65,7 @@ impl App {
owner: context.owner,
revision: context.revision,
epoch: lifetime,
scenario_view: None,
};
if self.behavior.has_pending()
|| self.draft_dirty()
@@ -0,0 +1,279 @@
//! Actual arrangement selection and current-record Details admission.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario::{CanvasEvent, Context, Shelf};
use crate::behavior::scenario_layout::Representation;
use iced::{Point, Size, advanced::widget};
const FIXTURE: &str = include_str!("diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn setup() -> (App, Id, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let owner = id(&app, "incident_response");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
choose(&mut app, Representation::Saved { diagram: a });
let _ = app.update(Message::Select(a));
app.window_size = Size::new(1440., 1000.);
(app, a, b, owner)
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn choose(app: &mut App, representation: Representation) {
send(app, |context| {
CanvasEvent::Representation(context, representation)
});
}
fn model_state(app: &App) -> (u64, bool, bool, bool) {
(
app.document.revision(),
app.document.dirty(),
app.document.can_undo(),
app.document.can_redo(),
)
}
#[tokio::test]
async fn actual_arrangement_picker_follows_selected_saved_record_and_opens_b_properties() {
let (mut app, a, b, _) = setup();
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let before = model_state(&app);
let captured = app.scenario_context().unwrap();
let picker = mounted.geometry(&app).bounds("scenario-arrangement-picker");
assert!(
app.window_size.height - (picker.y + picker.height) > picker.y,
"fixture has room for Iced's menu below the actual picker"
);
assert_eq!(app.selected, a);
mounted.click(&mut app, picker.center()).await;
// iced_widget 0.14.2 overlay/menu.rs uses default line height plus
// vertical padding for each row. The real workspace orders Automatic,
// then the fixture's A/B/C Diagram IDs. Select B from the actual overlay.
let row_height =
f32::from(iced::advanced::text::LineHeight::default().to_absolute(12.into())) + 10.;
let b_row = Point::new(
picker.center_x(),
picker.y + picker.height + row_height * 2.5,
);
assert!(b_row.y < app.window_size.height);
let events = mounted.click(&mut app, b_row).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Representation(context, Representation::Saved { diagram }))
if *context == captured && *diagram == b)), "must exercise the actual arrangement callback");
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.draft.as_ref().unwrap().value["name"],
"B - Incident review negative and far"
);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
send(&mut app, |context| {
CanvasEvent::Shelf(context, Shelf::Selection)
});
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (details, offset) = geometry.scrolls[&widget::Id::new("scenario-details")];
let point = geometry.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(_, selected)) if *selected == b)));
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (inspector, offset) = geometry.scrolls[&widget::Id::new("inspector")];
assert!(
geometry
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current B Properties must expose an actual editable field"
);
assert!(
!geometry
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_change_preserves_independent_semantic_and_foreign_view_selection() {
for alias in [
"initial_report",
"reporter",
"incident_response",
"incident_repair",
] {
let (mut app, _, b, _) = setup();
let selected = id(&app, alias);
let _ = app.update(Message::Select(selected));
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, selected, "independent selection {alias}");
assert_eq!(app.draft.as_ref().unwrap().original, selected);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
if alias == "incident_repair" {
assert!(!behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
selected
));
}
}
}
#[test]
fn arrangement_follows_view_record_to_automatic_owner_but_not_owner_back_to_saved() {
let (mut app, _, b, owner) = setup();
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Derived { owner });
assert_eq!(app.selected, owner);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, None);
assert!(behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
owner
));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(
app.selected, owner,
"Scenario owner is now independent semantic selection"
);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_applies_only_the_old_view_draft_then_follows_b_with_one_undo() {
let (mut app, a, b, _) = setup();
let snapshot = app.document.snapshot();
let mut expected = app.project().clone();
expected.diagrams.get_mut(&a).unwrap().name = "Reviewed A".into();
let revision = app.document.revision();
let _ = app.update(Message::Field("/name".into(), "Reviewed A".into()));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(!app.draft_dirty());
assert_eq!(app.document.revision(), revision + 1);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn same_arrangement_uuid_edit_follows_the_reidentified_record_and_round_trips_source() {
let (mut app, a, _, _) = setup();
let snapshot = app.document.snapshot();
let replacement = Id::parse_str("70200000-0000-4000-8000-000000000099").unwrap();
let mut expected = app.project().clone();
let mut diagram = expected.diagrams.remove(&a).unwrap();
diagram.id = replacement;
expected.diagrams.insert(replacement, diagram);
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, Representation::Saved { diagram: a });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, replacement);
assert_eq!(app.draft.as_ref().unwrap().original, replacement);
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(!app.document.can_redo());
}
#[test]
fn invalid_draft_and_stale_arrangement_preserve_selection_inputs_and_accepted_state() {
let (mut app, a, b, _) = setup();
let old = app.scenario_context().unwrap();
let snapshot = app.document.snapshot();
let before = model_state(&app);
let _ = app.update(Message::Field("/id".into(), "not-a-uuid".into()));
let draft_value = app.draft.as_ref().unwrap().value.clone();
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, a);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(
app.scenario_context().unwrap().representation,
old.representation
);
assert_eq!(app.draft.as_ref().unwrap().value, draft_value);
assert_eq!(app.draft.as_ref().unwrap().inputs["/id"].raw, "not-a-uuid");
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(model_state(&app), before);
let _ = app.update(Message::RevertProperties);
choose(&mut app, Representation::Saved { diagram: b });
let current = app.scenario_context().unwrap();
let _ = app.update(Message::Field("/name".into(), "Retained B input".into()));
let selected = app.selected;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
old,
Representation::Saved { diagram: a },
)));
assert_eq!(app.selected, selected);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.scenario_context().unwrap().visit, current.visit);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Retained B input"
);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
@@ -0,0 +1,96 @@
//! Saved Scenario navigation is presentation state, never a document edit.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation::checked_owner};
impl App {
pub(super) fn enter_scenario_representation(&mut self, representation: Representation) -> bool {
let Some(owner) = checked_owner(self.project(), representation) else {
self.status =
"This Scenario view is no longer available or has an incompatible owner.".into();
return false;
};
self.layer = self.project().elements[&owner].layer();
self.page = Page::Behavior;
self.behavior.active_owner = Some(owner);
match representation {
Representation::Saved { diagram } => self.active_diagram = Some(diagram),
Representation::Derived { .. } => {
if self
.active_diagram
.is_some_and(|id| self.saved_scenario_diagram(id))
{
self.active_diagram = None;
}
}
}
self.selected_node = None;
if self
.behavior
.scenario
.enter_representation(self.canvas_document, owner, representation)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
true
}
/// Reconcile after navigation/adoption without selecting or applying a draft.
/// A retained invalid Saved identity remains unavailable; it must not silently
/// turn into a different automatic Scenario or another saved arrangement.
pub(super) fn reconcile_scenario_workspace(&mut self) {
if self.page == Page::Diagram
&& !self.behavior.has_pending()
&& let Some(diagram) = self
.active_diagram
.filter(|id| self.saved_scenario_diagram(*id))
{
self.enter_scenario_representation(Representation::Saved { diagram });
}
if self.page != Page::Behavior {
self.behavior.scenario.leave();
return;
}
if let Some(rep @ Representation::Saved { diagram }) =
self.behavior.scenario.representation()
{
// The requested view is authoritative until explicit navigation
// retires it. This also recovers the same view when Undo restores it.
if let Some(owner) = checked_owner(self.project(), rep) {
self.active_diagram = Some(diagram);
self.layer = self.project().elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.behavior
.scenario
.enter_representation(self.canvas_document, owner, rep);
} else if let Some(owner) = self.behavior.scenario.owner() {
self.behavior
.scenario
.enter_representation(self.canvas_document, owner, rep);
}
return;
}
let Some(owner) = behavior::selected_owner(self) else {
self.behavior.scenario.leave();
return;
};
if self
.project()
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
self.behavior.scenario.enter(self.canvas_document, owner);
} else {
self.behavior.scenario.leave();
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod adoption_tests;
#[cfg(test)]
mod owner_navigation_tests;
@@ -0,0 +1,264 @@
//! Independent adoption oracles: expected Projects are edited directly in the
//! fixture, then admitted through the actual source editor/Document transaction.
//! Navigation is checked separately from the expected authored mutation.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use iced::advanced::widget;
use iced::widget::text_editor;
use oa_core::language;
use std::sync::Arc;
struct Fixture {
app: App,
owner: Id,
same_layer: Id,
other_layer: Id,
view: Id,
spare_view: Id,
}
fn fixture() -> Fixture {
let mut project = Project::empty();
let owner = project
.add_element(Kind::Scenario, Layer::Oa, "Original owner", None)
.unwrap();
let same_layer = project
.add_element(Kind::Scenario, Layer::Oa, "Other OA owner", None)
.unwrap();
let other_layer = project
.add_element(Kind::Scenario, Layer::Sa, "SA owner", None)
.unwrap();
// A remaining topology makes silent fallback observably different from the
// required unavailable Saved view after its owner is deleted.
project
.add_element(Kind::Process, Layer::Oa, "Remaining process", None)
.unwrap();
let mut make_view = |alias: &str, name: &str, owner| {
let id = Id::new_v4();
project.diagrams.insert(
id,
Diagram {
id,
alias: alias.into(),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
},
);
id
};
let view = make_view("retained_view", "Retained arrangement", owner);
let spare_view = make_view("spare_view", "Spare arrangement", same_layer);
let mut app = App::with_project(project);
let _ = app.update(Message::Select(owner));
choose(&mut app, view);
assert_saved(&app, view, owner, Layer::Oa);
assert!(!app.document.can_undo());
Fixture {
app,
owner,
same_layer,
other_layer,
view,
spare_view,
}
}
fn choose(app: &mut App, id: Id) {
let _ = app.update(Message::Diagram(Choice {
id,
label: "Previously captured picker item".into(),
}));
}
fn assert_saved(app: &App, view: Id, owner: Id, layer: Layer) {
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.layer, layer);
assert_eq!(app.active_diagram, Some(view));
let context = app
.scenario_context()
.expect("saved owner remains the visible context");
assert_eq!(context.scenario, owner);
assert_eq!(
context.representation,
Representation::Saved { diagram: view }
);
assert!(!context.visit.is_nil());
}
fn adopt_from_source(app: &mut App, expected: &Project) {
let text = language::format(expected);
// Parser equality is a fixture precondition, not the navigation oracle.
assert_eq!(language::parse(&text).unwrap(), *expected);
let _ = app.update(Message::Page(Page::Text));
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text)),
)));
assert!(app.source_dirty());
let _ = app.update(Message::ApplySource);
assert!(!app.source_dirty(), "{}", app.status);
assert_eq!(
app.project(),
expected,
"actual source transaction must adopt exactly the fixture change"
);
let _ = app.update(Message::Page(Page::Behavior));
}
fn retarget(cross_layer: bool) {
let mut f = fixture();
let before = f.app.document.snapshot();
let old_context = f.app.scenario_context().unwrap();
let revision = f.app.document.revision();
let target = if cross_layer {
f.other_layer
} else {
f.same_layer
};
let layer = if cross_layer { Layer::Sa } else { Layer::Oa };
let mut expected = before.decoded.project.clone();
let view = expected.diagrams.get_mut(&f.view).unwrap();
view.projection = ProjectionKind::Scenario { scenario: target };
view.layer = layer;
adopt_from_source(&mut f.app, &expected);
assert_saved(&f.app, f.view, target, layer);
assert_ne!(f.app.scenario_context().unwrap().visit, old_context.visit);
assert_eq!(
f.app.document.revision(),
revision + 1,
"routing must not create a second transaction"
);
assert_eq!(
f.app.project().diagrams[&f.spare_view],
before.decoded.project.diagrams[&f.spare_view]
);
let adopted_bundle = f.app.document.bundle().clone();
let _ = f.app.update(Message::Undo);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert!(
!f.app.document.can_undo(),
"exactly one authored adoption must be undone"
);
let _ = f.app.update(Message::Redo);
assert_saved(&f.app, f.view, target, layer);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted_bundle);
assert!(!f.app.document.can_redo());
}
#[test]
fn source_same_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
retarget(false);
}
#[test]
fn source_cross_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
retarget(true);
}
#[tokio::test]
async fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() {
let mut f = fixture();
let before = f.app.document.snapshot();
let mut expected = before.decoded.project.clone();
assert!(expected.elements.remove(&f.owner).is_some());
assert!(expected.diagrams.remove(&f.view).is_some());
assert!(expected.elements.contains_key(&f.same_layer));
assert!(
expected
.elements
.values()
.any(|e| e.kind() == Kind::Process)
);
adopt_from_source(&mut f.app, &expected);
assert_eq!(
f.app.behavior.scenario.representation(),
Some(Representation::Saved { diagram: f.view })
);
let adopted = f.app.document.snapshot();
let revision = f.app.document.revision();
let mut mounted = Mounted::new(&mut f.app).await;
let geometry = mounted.geometry(&f.app);
assert!(
geometry
.containers
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
);
assert!(
!geometry
.containers
.contains_key(&widget::Id::from("scenario-viewport"))
);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted.bundle);
assert_eq!(
f.app.document.revision(),
revision,
"mounting unavailable must remain passive"
);
let _ = f.app.update(Message::Undo);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.document.bundle(), &before.bundle);
let _ = f.app.update(Message::Redo);
assert_eq!(
f.app.behavior.scenario.representation(),
Some(Representation::Saved { diagram: f.view })
);
mounted.settle(&mut f.app).await;
assert!(
mounted
.geometry(&f.app)
.containers
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
);
assert_eq!(f.app.project(), &expected);
assert_eq!(f.app.document.bundle(), &adopted.bundle);
}
#[test]
fn missing_and_deleted_picker_ids_cannot_apply_an_unrelated_valid_property_draft() {
let mut f = fixture();
let mut expected = f.app.project().clone();
expected.diagrams.remove(&f.spare_view).unwrap();
adopt_from_source(&mut f.app, &expected);
let _ = f.app.update(Message::Select(f.owner));
let _ = f.app.update(Message::Field(
"/name".into(),
"Pending valid owner rename".into(),
));
assert!(f.app.draft_dirty());
let before = f.app.document.snapshot();
let revision = f.app.document.revision();
let selected = f.app.selected;
for invalid in [Id::nil(), f.spare_view] {
choose(&mut f.app, invalid);
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
assert_eq!(f.app.selected, selected);
assert!(f.app.draft_dirty());
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
assert_eq!(f.app.document.revision(), revision);
}
// Establish that the retained draft really was valid, not merely refused by
// the property validator: explicit Apply commits it in exactly one step.
assert!(f.app.apply(), "{}", f.app.status);
assert_eq!(
f.app.project().elements[&f.owner].name,
"Pending valid owner rename"
);
assert_eq!(f.app.document.revision(), revision + 1);
let _ = f.app.update(Message::Undo);
assert_eq!(f.app.project(), &before.decoded.project);
assert_eq!(f.app.document.bundle(), &before.bundle);
}
@@ -0,0 +1,116 @@
//! Explicit owner navigation must retire a retained Saved Scenario, including
//! when selection was made on a different page. These are real App events.
use super::*;
use crate::behavior::scenario::CanvasEvent;
fn setup(kind: Kind) -> (App, Id, Id) {
let mut project = Project::empty();
let scenario = project
.add_element(Kind::Scenario, Layer::Oa, "Incident review", None)
.unwrap();
let target = project
.add_element(kind, Layer::Oa, "Explicit destination", None)
.unwrap();
let view = Id::new_v4();
project.diagrams.insert(
view,
Diagram {
id: view,
alias: "incident_saved".into(),
name: "Incident saved arrangement".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario },
layout: None,
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
},
);
let errors: Vec<_> = oa_core::validation::validate(&project)
.into_iter()
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
let mut app = App::with_project(project);
let _ = app.update(Message::Select(scenario));
let _ = app.update(Message::Diagram(Choice {
id: view,
label: "Incident saved arrangement".into(),
}));
assert_eq!(app.page, Page::Behavior);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: view }
);
assert_eq!(app.selected, scenario);
(app, scenario, target)
}
fn assert_destination(app: &App, target: Id) {
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, target);
assert_eq!(app.behavior.active_owner, Some(target));
assert_eq!(behavior::selected_owner(app), Some(target));
assert_eq!(
app.behavior.scenario.representation(),
None,
"explicit non-Scenario destination must not retain an overriding Saved request"
);
assert!(app.scenario_context().is_none());
}
fn exercise(via_guide: bool) {
for kind in [Kind::Process, Kind::StateMachine] {
let (mut app, scenario, target) = setup(kind);
assert_ne!(scenario, target);
let held = app.scenario_context().unwrap();
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let dirty = (app.document.dirty(), app.dirty(), app.source_dirty());
assert!(!app.document.can_undo() && !app.document.can_redo());
if via_guide {
let _ = app.update(Message::Page(Page::Guide));
assert_eq!(app.page, Page::Guide);
assert!(app.scenario_context().is_none());
}
let _ = app.update(Message::Select(target));
if via_guide {
assert_eq!(
app.page,
Page::Guide,
"explorer selection must not itself switch the page"
);
let _ = app.update(Message::Page(Page::Behavior));
}
assert_destination(&app, target);
let camera = app.camera_boundary();
// The prior saved canvas cannot regain focus after either owner route.
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Shelf(
held,
crate::behavior::scenario::Shelf::Selection,
)));
assert_destination(&app, target);
assert!(app.camera_boundary() == camera);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(app.document.dirty(), app.dirty(), app.source_dirty()),
dirty
);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn saved_scenario_guide_selection_returns_to_explicit_process_or_state_owner() {
exercise(true);
}
#[test]
fn saved_scenario_direct_behavior_selection_enters_explicit_process_or_state_owner() {
exercise(false);
}
@@ -0,0 +1,205 @@
use super::*;
use crate::behavior::scenario::{CanvasEvent, ScrollArea};
fn saved(p: &mut Project, owner: Id, name: &str) -> Id {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: format!("view_{}", p.diagrams.len()),
name: name.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
layout: None,
routes: vec![],
nodes: Default::default(),
view_options: Default::default(),
},
);
id
}
fn setup() -> (App, Id, Id, Id, Id) {
let (mut p, [owner, callee, _], [reference, _, _]) =
behavior::scenario::direct_tests::navigation_fixture();
let a = saved(&mut p, owner, "Review A");
let b = saved(&mut p, owner, "Review B");
let mut app = App::with_project(p);
app.select(owner);
(app, a, b, reference, callee)
}
fn choose(app: &mut App, diagram: Id) {
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Captured view".into(),
}));
}
fn event(app: &mut App, f: impl FnOnce(behavior::scenario::Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(f(context)));
}
#[test]
fn saved_view_switches_are_distinct_passive_visits_and_preserve_actual_history() {
let (mut app, a, b, _, _) = setup();
let original = app.document.snapshot();
let owner = app.selected;
assert!(app.execute(Command::Rename(owner, "Previously edited caller".into())));
let edited = app.document.snapshot();
let edited_revision = app.document.revision();
choose(&mut app, a);
let first = app.scenario_context().unwrap();
choose(&mut app, b);
let second = app.scenario_context().unwrap();
choose(&mut app, a);
let third = app.scenario_context().unwrap();
assert_eq!(first.scenario, owner);
assert_eq!(first.representation, Representation::Saved { diagram: a });
assert_eq!(second.representation, Representation::Saved { diagram: b });
assert_ne!(first.visit, second.visit);
assert_ne!(first.visit, third.visit);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, owner);
assert_eq!(app.document.bundle(), &edited.bundle);
assert_eq!(app.project(), &edited.decoded.project);
assert_eq!(app.document.revision(), edited_revision);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &edited.bundle);
assert_eq!(app.project(), &edited.decoded.project);
}
#[test]
fn saved_view_return_rejects_old_passive_events_without_an_intermediate_frame() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let old = app.scenario_context().unwrap();
choose(&mut app, b);
choose(&mut app, a);
let current = app.scenario_context().unwrap();
let camera = app.behavior.scenario.camera_boundary();
let before = app.document.snapshot();
for stale in [
CanvasEvent::Viewport(old, iced::Size::new(432., 321.)),
CanvasEvent::Scrolled(old, ScrollArea::Page, iced::Vector::new(0., 800.)),
CanvasEvent::FormShown(old, Id::new_v4()),
CanvasEvent::Restored(
old,
Id::new_v4(),
ScrollArea::Page,
iced::Vector::new(0., 800.),
),
CanvasEvent::Camera(
old,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(90., 80.)),
),
] {
let _ = app.update(Message::ScenarioCanvas(stale));
assert_eq!(app.scenario_context(), Some(current));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.document.bundle(), &before.bundle);
}
}
#[test]
fn saved_reference_back_restores_exact_view_with_a_fresh_visit() {
let (mut app, a, _, reference, callee) = setup();
choose(&mut app, a);
let caller = app.scenario_context().unwrap();
let before = app.document.snapshot();
event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
let target = app.scenario_context().unwrap();
assert_eq!(target.scenario, callee);
assert_eq!(
target.representation,
Representation::Derived { owner: callee }
);
assert_eq!(app.active_diagram, None);
event(&mut app, CanvasEvent::Back);
let returned = app.scenario_context().unwrap();
assert_eq!(returned.representation, caller.representation);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(app.selected, reference);
assert_ne!(returned.visit, caller.visit);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert!(!app.document.can_undo());
}
#[test]
fn saved_picker_applies_valid_old_draft_and_uuid_edit_follows_that_same_view() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let _ = app.update(Message::Select(a));
let before = app.document.snapshot();
let _ = app.update(Message::Field("/name".into(), "Renamed A".into()));
choose(&mut app, b);
assert_eq!(app.project().diagrams[&a].name, "Renamed A");
assert_eq!(app.selected, b);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
choose(&mut app, a);
let replacement = Id::new_v4();
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, a);
assert!(!app.project().diagrams.contains_key(&a));
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(app.selected, replacement);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
}
#[test]
fn leaving_and_reentering_saved_view_preserves_source_and_retires_passive_visit() {
let (mut app, a, _, _, _) = setup();
choose(&mut app, a);
let old = app.scenario_context().unwrap();
let before = app.document.snapshot();
let _ = app.update(Message::Page(Page::Guide));
assert!(app.scenario_context().is_none());
let _ = app.update(Message::Page(Page::Behavior));
let fresh = app.scenario_context().unwrap();
assert_eq!(fresh.representation, old.representation);
assert_ne!(fresh.visit, old.visit);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn arrangement_picker_rejects_previous_visit_and_switches_automatic_without_model_changes() {
let (mut app, a, b, _, _) = setup();
choose(&mut app, a);
let stale = app.scenario_context().unwrap();
choose(&mut app, b);
let current = app.scenario_context().unwrap();
let before = app.document.snapshot();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
stale,
Representation::Saved { diagram: a },
)));
assert_eq!(app.scenario_context(), Some(current));
event(&mut app, |context| {
CanvasEvent::Representation(
context,
Representation::Derived {
owner: context.scenario,
},
)
});
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Derived {
owner: current.scenario
}
);
assert_eq!(app.active_diagram, None);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert!(!app.document.can_undo());
}
@@ -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<Message> {
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()
}
}
@@ -0,0 +1,335 @@
use super::*;
use behavior::scenario::{
CanvasEvent,
spacing::{
Target,
controller::tests::{fixture, packet},
},
};
use iced::{Point, Size, Vector};
fn setup() -> (App, Id, Id) {
let (document, owner, diagram, lane, message) = fixture();
let mut app = App::with_project(document.project().clone());
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Saved".into(),
}));
let c = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
c,
Size::new(900., 600.),
)));
assert!(app.spacing_enabled());
(app, lane, message)
}
#[test]
fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture() {
let (mut app, lane, _) = setup();
let previous = app.scenario_context().unwrap();
let diagram = match previous.representation {
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
_ => unreachable!(),
};
let _ = app.update(Message::Page(Page::Text));
let old_indent = Message::SourceIndent(app.source_symbol_context(), false);
let old_drawer = Message::TextWorkspace(text_workspace::Event::ShowDrawer(
app.text_drawer_context(),
text_workspace::DrawerTab::Problems,
));
let old_command = app.text_command_message(text_workspace::Command::ShowStatus);
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Saved".into(),
}));
let context = app.scenario_context().unwrap();
assert_ne!(context.visit, previous.visit);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
Size::new(900., 600.),
)));
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(24., 0.));
let accepted = app.document.snapshot();
let source = app.source_workspace.bundle();
let source_version = app.source_version;
let camera = app.camera_boundary();
let selected = app.selected;
let events = [
old_indent,
old_drawer,
old_command,
Message::ScenarioCanvas(CanvasEvent::Shelf(
previous,
behavior::scenario::Shelf::Selection,
)),
Message::ScenarioCanvas(CanvasEvent::Properties(previous, lane)),
];
for rejected in events {
let _ = app.update(rejected);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
assert_eq!(app.behavior.scenario.spacing_stamp(), Some(event.stamp()));
assert!(app.camera_boundary() == camera);
assert_eq!(app.selected, selected);
assert_eq!(app.source_version, source_version);
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.project(), &accepted.decoded.project);
assert!(app.text_workspace.drawer().is_none());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
finish(&mut app, event, start + Vector::new(24., 0.));
assert!(
app.document.can_undo(),
"the current gesture remains usable"
);
}
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
let event = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
target,
);
let Event::Begin {
press_screen,
scale,
..
} = event
else {
unreachable!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
(event, press_screen, scale)
}
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
}
fn move_to(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Move {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
);
}
fn finish(app: &mut App, begin: Event, screen: Point) {
send(
app,
Event::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen,
},
);
}
#[test]
fn same_batch_preview_is_immutable_and_one_commit_undo_redo_has_exact_source_parity() {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let source = app.source_workspace.bundle();
let selected = app.selected;
let epoch = app.canvas_epoch;
let (event, start, scale) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(40., 300.));
assert_eq!(app.selected, selected);
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.source_workspace.bundle(), source);
assert!(!app.document.can_undo() && !app.document.can_redo());
finish(&mut app, event, start + Vector::new(40., 300.));
assert_eq!(app.selected, lane);
assert!(app.document.can_undo() && !app.document.can_redo());
let accepted = app.document.snapshot();
assert_ne!(accepted.bundle, original.bundle);
let expected = behavior::scenario::spacing::controller::tests::expected(
&original.decoded.project,
match event.stamp().context.representation {
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
_ => unreachable!(),
},
Target::Lane(lane),
40. / scale,
);
assert_eq!(app.project(), &expected);
let _ = app.update(Message::ShortcutUndo(false));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::ShortcutUndo(true));
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.document.bundle(), &accepted.bundle);
}
#[test]
fn click_selects_but_crossed_return_and_cancel_preserve_selection_camera_and_history() {
let (mut app, lane, message) = setup();
let original = app.document.snapshot();
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Message(message));
finish(&mut app, event, start + Vector::new(30., 2.));
assert_eq!(app.selected, message);
assert!(camera == app.behavior.scenario.camera_boundary());
for cancel in [false, true] {
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
if cancel {
send(
&mut app,
Event::Cancel {
stamp: event.stamp(),
token: event.token(),
},
);
} else {
finish(&mut app, event, start);
}
assert_eq!(app.selected, message);
assert!(camera == app.behavior.scenario.camera_boundary());
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.project(), &original.decoded.project);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn stale_finish_after_new_view_and_forged_token_are_inert_before_reveal_reconciliation() {
let (mut app, lane, _) = setup();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
let held_epoch = app.canvas_epoch;
send(
&mut app,
Event::Finish {
stamp: event.stamp(),
token: Id::new_v4(),
screen: start + Vector::new(30., 0.),
},
);
assert_eq!(app.canvas_epoch, held_epoch);
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
let owner = event.stamp().context.scenario;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
event.stamp().context,
behavior::scenario_layout::Representation::Derived { owner },
)));
let current = app.scenario_context();
let epoch = app.canvas_epoch;
let original = app.document.snapshot();
finish(&mut app, event, start + Vector::new(50., 0.));
assert_eq!(app.scenario_context(), current);
assert_eq!(app.canvas_epoch, epoch);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn source_write_failure_cannot_commit_candidate_or_materialize_camera() {
let (mut app, lane, _) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum();
app.document = Document::from_bundle(bundle, limits).unwrap();
app.sync();
let original = app.document.snapshot();
let selected = app.selected;
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(40., 0.));
finish(&mut app, event, start + Vector::new(40., 0.));
assert!(
app.status.starts_with("Spacing was not applied:"),
"{}",
app.status
);
assert_eq!(app.selected, selected);
assert!(camera == app.behavior.scenario.camera_boundary());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
assert!(app.behavior.scenario.spacing_token().is_none());
}
#[test]
fn pending_semantic_draft_and_source_edit_cancel_and_reject_held_finish() {
for source in [false, true] {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let selected = app.selected;
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
if source {
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Insert(' '),
)));
assert!(app.source_dirty());
} else {
let _ = app.update(Message::Behavior(behavior::Event::Scenario(
behavior::scenario::Event::Start(
event.stamp().context.scenario,
behavior::scenario::Tool::Lifeline,
),
)));
assert!(app.behavior.has_pending());
}
let draft = app.source_workspace.bundle();
assert!(app.behavior.scenario.spacing_token().is_none());
finish(&mut app, event, start + Vector::new(30., 0.));
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert_eq!(app.source_workspace.bundle(), draft);
assert_eq!(app.selected, selected);
assert!(!app.document.can_undo());
}
}
#[test]
fn stale_finish_cannot_remove_new_same_view_deferred_reveal() {
let (mut app, lane, message) = setup();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
let behavior::scenario_layout::Representation::Saved { diagram } =
event.stamp().context.representation
else {
unreachable!()
};
let _ = app.update(Message::RevealInDiagram(diagram, message));
assert!(app.pending_diagram_reveal.is_some());
let epoch = app.canvas_epoch;
let selected = app.selected;
let visit = app.pending_scenario_reveal_visit;
let original = app.document.snapshot();
finish(&mut app, event, start + Vector::new(50., 0.));
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.pending_scenario_reveal_visit, visit);
assert!(app.pending_diagram_reveal.is_some());
assert_eq!(app.selected, selected);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn modal_open_cancels_preview_and_captured_begin_stays_retired_after_close() {
let (mut app, lane, _) = setup();
let original = app.document.snapshot();
let camera = app.behavior.scenario.camera_boundary();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(30., 0.));
let _ = app.update(Message::Delete);
assert!(app.delete_pending);
assert!(app.behavior.scenario.spacing_token().is_none());
finish(&mut app, event, start + Vector::new(30., 0.));
let _ = app.update(Message::CancelDelete);
send(&mut app, event);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(app.project(), &original.decoded.project);
assert_eq!(app.document.bundle(), &original.bundle);
assert!(camera == app.behavior.scenario.camera_boundary());
assert!(!app.document.can_undo());
}
@@ -24,6 +24,7 @@ impl App {
if self.behavior.has_pending() {
return;
}
self.behavior.scenario.forget_view();
self.layer = layer;
self.page = Page::Behavior;
self.behavior.active_owner = Some(machine);
@@ -49,11 +49,28 @@ fn fixture(layer: Layer, kind: Kind) -> (App, Id, Id, Id) {
p.diagrams.get_mut(&view).unwrap().name = "Unrelated architecture".into();
p.place(view, reused, Position { x: 873., y: 421. });
let mut app = App::with_project(p);
app.layer = layer;
app.page = Page::Behavior;
app.selected = root;
app.behavior.active_owner = Some(root);
app.refresh_draft();
let accepted = app.document.snapshot();
// Enter through the actual navigation path: assigning Page/owner alone does
// not create the representation and live visit required by canvas events.
send(&mut app, Message::Select(root));
send(&mut app, Message::Page(Page::Behavior));
assert_eq!(app.layer, layer);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.selected, root);
assert_eq!(app.behavior.active_owner, Some(root));
if kind == Kind::Scenario {
let context = app.scenario_context().unwrap();
assert_eq!(context.document, app.canvas_document);
assert_eq!(context.scenario, root);
assert_eq!(
context.representation,
crate::behavior::scenario_layout::Representation::Derived { owner: root }
);
assert!(!context.visit.is_nil());
}
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
(app, root, work, reused)
}
+372 -45
View File
@@ -21,6 +21,11 @@ pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
mod scenario_activation;
#[cfg(test)]
mod scenario_hit_tests;
// Pure saved-geometry bridge; workspace integration is a separate checkpoint.
#[allow(dead_code)]
pub(crate) mod scenario_layout;
mod scenario_lifecycle;
pub(crate) mod source_draft;
pub(crate) mod state_canvas;
@@ -59,9 +64,18 @@ mod scenario_scroll {
process: bool,
restore: Option<scenario::ScrollRestore>,
}
type Report = Option<(bool, Id, Id, Vector)>;
type Identity = (Id, Id, scenario_layout::Representation, Id);
fn identity(context: scenario::Context) -> Identity {
(
context.document,
context.scenario,
context.representation,
context.visit,
)
}
type Report = Option<(bool, Identity, Vector)>;
struct ObservedScroll {
identity: (Id, Id),
identity: Identity,
report: Report,
restored: Option<Id>,
}
@@ -112,8 +126,12 @@ mod scenario_scroll {
) {
let restore = self.restore.filter(|request| {
!self.process
&& (request.document, request.owner)
== (self.context.document, self.context.scenario)
&& (
request.document,
request.owner,
request.representation,
request.visit,
) == identity(self.context)
&& tree.state.downcast_ref::<ObservedScroll>().restored != Some(request.token)
});
let receipt = restore.and_then(|request| {
@@ -137,12 +155,7 @@ mod scenario_scroll {
&mut offset,
);
if let Some(offset) = offset.0 {
let next = Some((
self.process,
self.context.document,
self.context.scenario,
offset,
));
let next = Some((self.process, identity(self.context), offset));
let state = tree.state.downcast_mut::<ObservedScroll>();
if let Some(token) = receipt {
state.restored = Some(token);
@@ -186,7 +199,7 @@ mod scenario_scroll {
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(ObservedScroll {
identity: (self.context.document, self.context.scenario),
identity: identity(self.context),
report: None,
restored: None,
})
@@ -195,9 +208,7 @@ mod scenario_scroll {
vec![widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
if tree.state.downcast_ref::<ObservedScroll>().identity
!= (self.context.document, self.context.scenario)
{
if tree.state.downcast_ref::<ObservedScroll>().identity != identity(self.context) {
// A new owner/document needs fresh real scroll state, not merely
// cleared mirrored offsets. Edits and tool/form changes retain it.
tree.state = self.state();
@@ -486,17 +497,25 @@ mod scenario_scroll {
}
#[test]
fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() {
for mismatch in 0..3 {
for mismatch in 0..5 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut request = scenario::ScrollRestore {
document: context.document,
owner: context.scenario,
representation: context.representation,
visit: context.visit,
token: Id::new_v4(),
offset: Vector::new(0., 180.),
};
match mismatch {
0 => request.document = Id::new_v4(),
1 => request.owner = Id::new_v4(),
3 => {
request.representation = scenario_layout::Representation::Saved {
diagram: Id::new_v4(),
}
}
4 => request.visit = Id::new_v4(),
_ => {}
}
let mut view = observer(600., context);
@@ -583,7 +602,7 @@ mod scenario_scroll {
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
for process in [false, true] {
let area = scenario::ScrollArea::Page;
for change in 0..4 {
for change in 0..6 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut view = observer(600., context);
view.process = process;
@@ -612,6 +631,12 @@ mod scenario_scroll {
next.revision += 1;
next.epoch += 5;
}
4 => {
next.representation = scenario_layout::Representation::Saved {
diagram: Id::new_v4(),
}
}
5 => next.visit = Id::new_v4(),
_ => {}
}
view = observer(if change == 3 { 800. } else { 600. }, next);
@@ -659,7 +684,7 @@ mod scenario_scroll {
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if change < 2 {
vec![if !(2..4).contains(&change) {
Vector::ZERO
} else {
Vector::new(0., 180.)
@@ -973,6 +998,19 @@ pub fn view(app: &App) -> Element<'_, Message> {
return container(out).padding(16).width(Fill).height(Fill).into();
}
}
// A requested saved view remains the destination even after its model is removed.
// The shared display adapter reports unavailable instead of choosing another behavior.
if matches!(
app.behavior.scenario.representation(),
Some(scenario_layout::Representation::Saved { .. })
) && let Some(owner) = app.behavior.scenario.owner()
&& !p
.elements
.get(&owner)
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == app.layer)
{
return scenario::view(app, owner, column![].spacing(6));
}
let choices = p
.elements
.values()
@@ -984,7 +1022,14 @@ pub fn view(app: &App) -> Element<'_, Message> {
label: format!("{} · {}", e.kind(), e.name),
})
.collect::<Vec<_>>();
let owner = selected_owner(app);
let owner = if matches!(
app.behavior.scenario.representation(),
Some(scenario_layout::Representation::Saved { .. })
) {
app.behavior.scenario.owner()
} else {
selected_owner(app)
};
let selected = owner
.and_then(|id| choices.iter().find(|c| c.id == id))
.or(choices.first())
@@ -1118,8 +1163,27 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
false
}
/// Identifies the drawn primitive that won selection, not an authoring permission.
/// In particular an activation and its message can share an occurrence UUID.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SceneHitRole {
LifelineHeader,
MessageBody,
MessageLabel,
Activation,
LifecycleMarker,
LifelineGuide,
Box,
OtherLine,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct SceneHit {
id: Id,
role: SceneHitRole,
}
struct BoxItem {
id: Id,
role: SceneHitRole,
rect: Rectangle,
title: String,
detail: String,
@@ -1152,6 +1216,7 @@ fn box_text_clip(
struct Line {
id: Id,
role: SceneHitRole,
points: Vec<Point>,
label: String,
label_at: Point,
@@ -1172,12 +1237,17 @@ struct Scene {
boxes: Vec<BoxItem>,
lines: Vec<Line>,
size: Size,
layout_origin: Point,
selected: Id,
scale: f32,
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
spacing_stamp: Option<scenario::spacing::Stamp>,
coordinate_input: bool,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
lanes: BTreeMap<Id, f32>,
// Exact render coordinates include unassigned lanes; do not derive from cards.
render_lanes: BTreeMap<Id, f32>,
slots: Vec<SequenceSlot>,
rows: BTreeMap<Id, (Id, f32)>,
activations: Vec<scenario_activation::Segment>,
@@ -1201,6 +1271,10 @@ struct SequenceInput {
}
#[derive(Clone, Copy)]
enum SequencePress {
Spacing {
stamp: scenario::spacing::Stamp,
token: Id,
},
Occurrence {
tool: scenario::Tool,
slot: scenario::Slot,
@@ -1221,6 +1295,19 @@ enum SequencePress {
},
}
impl SequenceInput {
fn cancel_spacing(&mut self) -> Option<Action<Message>> {
let cancel = match self.press {
Some(SequencePress::Spacing { stamp, token }) => Some(
Action::publish(Message::ScenarioCanvas(scenario::CanvasEvent::Spacing(
scenario::spacing::Event::Cancel { stamp, token },
)))
.and_capture(),
),
_ => None,
};
self.cancel();
cancel
}
fn cancel(&mut self) {
self.source = None;
self.press = None;
@@ -1232,17 +1319,31 @@ impl Scene {
&& (self.boxes.iter().any(|item| item.id == id)
|| self.lines.iter().any(|item| item.id == id))
}
// The integrated removal guard also needs the derived semantic hit set.
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
Self::sequence_geometry(p, owner, selected, None, None)
}
fn sequence_geometry(
p: &Project,
owner: Id,
selected: Id,
lane_override: Option<&BTreeMap<Id, f32>>,
plan: Option<&scenario_layout::Plan>,
) -> Self {
let mut scene = Self {
scale: 1.,
camera_pan: iced::Vector::ZERO,
boxes: vec![],
lines: vec![],
size: Size::new(700., 200.),
layout_origin: Point::ORIGIN,
selected,
interaction: None,
spacing_stamp: None,
coordinate_input: true,
pending_placement: None,
lanes: BTreeMap::new(),
render_lanes: BTreeMap::new(),
slots: vec![],
rows: BTreeMap::new(),
activations: vec![],
@@ -1313,12 +1414,32 @@ impl Scene {
})
.collect();
}
let lane_x = lanes
let mut lane_x = lanes
.iter()
.enumerate()
.map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.))
.collect::<BTreeMap<_, _>>();
if let Some(values) = lane_override {
for (id, x) in values {
if let Some(lane) = lane_x.get_mut(id) {
*lane = *x;
}
}
}
scene.render_lanes = lane_x.clone();
scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.);
let layout_left = if lane_override.is_some() {
lane_x.values().fold(0_f32, |left, x| left.min(*x - 160.))
} else {
0.
};
scene.layout_origin.x = layout_left;
if lane_override.is_some() {
scene.size.width = lane_x
.values()
.fold(scene.size.width, |right, x| right.max(*x + 240.))
- layout_left;
}
if scenario {
scene.lanes = lane_x
.iter()
@@ -1334,8 +1455,26 @@ impl Scene {
.collect();
}
let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
scene.size.height = (y + 45.).max(210.);
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0, plan);
if layout_left != 0. {
for item in &mut scene.boxes {
let body = scenario_layout::occurrence(p, item.id);
if matches!(
body,
Some(
OccurrenceBody::Branch { .. }
| OccurrenceBody::ScenarioUse { .. }
| OccurrenceBody::Control { .. }
| OccurrenceBody::SequenceReference { .. }
| OccurrenceBody::Exchange { .. }
)
) || (plan.is_none() && matches!(body, Some(OccurrenceBody::Fragment { .. })))
{
item.rect.x += layout_left;
}
}
}
scene.size.height = plan.map_or((y + 45.).max(210.), |plan| plan.height);
if scenario {
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
p,
@@ -1346,6 +1485,9 @@ impl Scene {
scene.size.height,
);
scene.lifecycle.align_messages(&mut scene.lines);
if let Some(plan) = plan {
plan.align_message_anchors(&mut scene.lines);
}
scene.layout_activations(p, &lane_x);
}
for (id, name, detail) in lanes {
@@ -1355,6 +1497,7 @@ impl Scene {
0,
Line {
id,
role: SceneHitRole::LifelineGuide,
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
label: String::new(),
label_at: Point::ORIGIN,
@@ -1364,6 +1507,11 @@ impl Scene {
}
scene.boxes.push(BoxItem {
id,
role: if scenario {
SceneHitRole::LifelineHeader
} else {
SceneHitRole::Box
},
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
title: name,
detail,
@@ -1372,6 +1520,7 @@ impl Scene {
}
scene
}
#[allow(clippy::too_many_arguments)] // Explicit complete plan replaces ambient geometry state.
fn occurrences(
&mut self,
p: &Project,
@@ -1380,6 +1529,7 @@ impl Scene {
lanes: &BTreeMap<Id, f32>,
y: &mut f32,
depth: usize,
plan: Option<&scenario_layout::Plan>,
) {
let steps = p
.elements
@@ -1419,6 +1569,9 @@ impl Scene {
}
#[cfg(test)]
scenario_lifecycle::render::apply_test_row_offset(*id, y);
if let Some(plan) = plan {
*y = plan.anchors[id].y;
}
if ordered {
let slot = scenario::Slot {
parent: owner,
@@ -1435,7 +1588,7 @@ impl Scene {
OccurrenceBody::Fragment { operator, .. } => {
let top = *y;
*y += 55.;
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
let rect = Rectangle::new(
Point::new(18. + depth as f32 * 10., top),
Size::new(
@@ -1443,10 +1596,12 @@ impl Scene {
(*y - top).max(95.),
),
);
let rect = plan.map_or(rect, |plan| plan.frames[id]);
self.boxes.insert(
0,
BoxItem {
id: *id,
role: SceneHitRole::Box,
rect,
title: format!("{operator:?} · {}", e.name),
detail: String::new(),
@@ -1458,6 +1613,7 @@ impl Scene {
OccurrenceBody::Branch { guard, .. } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(36. + depth as f32 * 10., *y),
Size::new(self.size.width - 85. - depth as f32 * 20., 43.),
@@ -1474,7 +1630,7 @@ impl Scene {
marker: Some(Kind::Occurrence),
});
*y += if scenario { 90. } else { 62. };
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
}
OccurrenceBody::Exchange {
exchange,
@@ -1502,7 +1658,7 @@ impl Scene {
source.and_then(|id| lanes.get(&id)),
target.and_then(|id| lanes.get(&id)),
) {
let points = if a == b {
let points = if source == target {
vec![
Point::new(*a, *y),
Point::new(a + 75., *y),
@@ -1541,6 +1697,7 @@ impl Scene {
}
self.lines.push(Line {
id: *id,
role: SceneHitRole::MessageBody,
points,
label: format!("{}. {kind}{}", index + 1, message_label),
label_at: Point::new(a.min(*b) + 12., *y - 26.),
@@ -1549,6 +1706,7 @@ impl Scene {
} else {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 65.),
@@ -1571,6 +1729,7 @@ impl Scene {
.unwrap_or(180.);
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)),
title: e.name.clone(),
detail: action
@@ -1583,6 +1742,7 @@ impl Scene {
OccurrenceBody::Control { kind } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)),
title: format!("{kind:?} · {}", e.name),
detail: "Contextual process control".into(),
@@ -1593,6 +1753,7 @@ impl Scene {
OccurrenceBody::SequenceReference { sequence } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)),
title: e.name.clone(),
detail: sequence
@@ -1605,6 +1766,7 @@ impl Scene {
OccurrenceBody::ScenarioUse { scenario, bindings } => {
self.boxes.push(BoxItem {
id: *id,
role: SceneHitRole::Box,
rect: Rectangle::new(
Point::new(65., *y - 20.),
Size::new(self.size.width - 130., 70.),
@@ -1627,6 +1789,10 @@ impl Scene {
}
if ordered {
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
if let Some(plan) = plan {
self.sequence_bounds.insert(owner, plan.scopes[&owner]);
*y = plan.scopes[&owner].1 + 42.;
}
let slot = scenario::Slot {
parent: owner,
before: None,
@@ -1666,11 +1832,21 @@ impl Scene {
))
}
fn hit(&self, point: Point) -> Option<Id> {
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<SceneHit> {
if !point.x.is_finite() || !point.y.is_finite() {
return None;
}
if let Some(id) = self.lifecycle.marker_at(point) {
return Some(id);
return Some(SceneHit {
id,
role: SceneHitRole::LifecycleMarker,
});
}
self.boxes
.iter()
@@ -1680,7 +1856,10 @@ impl Scene {
&& b.rect.contains(point)
&& (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.)
})
.map(|b| b.id)
.map(|b| SceneHit {
id: b.id,
role: b.role,
})
.or_else(|| {
self.lines
.iter()
@@ -1690,26 +1869,51 @@ impl Scene {
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
})
})
.map(|line| line.id)
.map(|line| SceneHit {
id: line.id,
role: line.role,
})
})
.or_else(|| {
self.activations
.iter()
.rev()
.find(|b| b.rect.contains(point))
.map(|b| b.call)
.map(|b| SceneHit {
id: b.call,
role: SceneHitRole::Activation,
})
})
.or_else(|| {
self.lines
.iter()
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
.map(|line| line.id)
.map(|line| SceneHit {
id: line.id,
role: if line.role == SceneHitRole::MessageBody {
SceneHitRole::MessageLabel
} else {
SceneHitRole::OtherLine
},
})
})
// The generous message tolerance must not steal the exposed
// shoulder of another call's execution when the view is zoomed out.
.or_else(|| self.near_sequence_item(point))
.or_else(|| self.lifecycle.lane_at(point, self.scale))
.or_else(|| self.lane_at(point))
.or_else(|| {
self.lifecycle
.lane_at(point, self.scale)
.map(|id| SceneHit {
id,
role: SceneHitRole::LifelineGuide,
})
})
.or_else(|| {
self.lane_at(point).map(|id| SceneHit {
id,
role: SceneHitRole::LifelineGuide,
})
})
.or_else(|| {
self.lines
.iter()
@@ -1720,11 +1924,14 @@ impl Scene {
< 8. / self.scale
})
})
.map(|line| line.id)
.map(|line| SceneHit {
id: line.id,
role: line.role,
})
})
}
fn near_sequence_item(&self, point: Point) -> Option<Id> {
fn near_sequence_item(&self, point: Point) -> Option<SceneHit> {
if !self.scale.is_finite() || self.scale <= 0. {
return None;
}
@@ -1738,17 +1945,32 @@ impl Scene {
.windows(2)
.map(|p| crate::diagram::distance_to_segment(point, p[0], p[1]))
.fold(f32::INFINITY, f32::min);
(line.id, distance)
(
SceneHit {
id: line.id,
role: line.role,
},
distance,
)
});
// Compare broad message and lifecycle-marker tolerance together after
// exact shapes, so dense minimum-Fit rows choose the nearer item.
self.lifecycle
.nearest_marker(point)
.map(|(id, distance)| {
(
SceneHit {
id,
role: SceneHitRole::LifecycleMarker,
},
distance,
)
})
.into_iter()
.chain(messages)
.filter(|(_, distance)| *distance <= 8. / self.scale)
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
.map(|(id, _)| id)
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.id.cmp(&b.0.id)))
.map(|(hit, _)| hit)
}
}
impl canvas::Program<Message> for Scene {
@@ -1776,15 +1998,33 @@ impl canvas::Program<Message> for Scene {
// captured insertion slot belongs to the previous view, even if the
// pointer itself did not move and the semantic context is unchanged.
if state.bounds.is_some_and(|previous| previous != bounds) {
state.cancel();
let cancelled = state.cancel_spacing();
state.pointer = None;
state.bounds = Some(bounds);
if cancelled.is_some() {
return cancelled;
}
}
state.bounds = Some(bounds);
if !editable {
state.cancel();
if (!editable || !self.coordinate_input)
&& let Some(cancelled) = state.cancel_spacing()
{
return Some(cancelled);
}
// The viewport masks input outside its visible canvas bounds.
let visible = cursor.position().is_some();
if !visible
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
&& matches!(
event,
canvas::Event::Mouse(
mouse::Event::CursorMoved { .. }
| mouse::Event::ButtonReleased(mouse::Button::Left)
)
)
{
return state.cancel_spacing();
}
if !visible
&& matches!(
event,
@@ -1803,7 +2043,7 @@ impl canvas::Program<Message> for Scene {
| SequencePress::Reorder { start, moved, .. } => {
*moved |= position.distance(*start) >= 3.
}
SequencePress::Occurrence { .. } => {}
SequencePress::Occurrence { .. } | SequencePress::Spacing { .. } => {}
}
}
mouse::Cursor::Available(*position)
@@ -1812,9 +2052,9 @@ impl canvas::Program<Message> for Scene {
| canvas::Event::Window(iced::window::Event::Resized(_))
| canvas::Event::Mouse(mouse::Event::CursorLeft)
| canvas::Event::Window(iced::window::Event::Unfocused) => {
state.cancel();
let cancelled = state.cancel_spacing();
state.pointer = None;
return Some(Action::request_redraw());
return cancelled.or_else(|| Some(Action::request_redraw()));
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
@@ -1850,8 +2090,9 @@ impl canvas::Program<Message> for Scene {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) if state.source.is_some() || state.press.is_some() => {
state.cancel();
return Some(Action::request_redraw().and_capture());
return state
.cancel_spacing()
.or_else(|| Some(Action::request_redraw().and_capture()));
}
_ => cursor,
};
@@ -1871,6 +2112,14 @@ impl canvas::Program<Message> for Scene {
if !editable {
return None;
}
if !self.coordinate_input {
return self.hit(point).map(|id| {
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
context, id,
)))
.and_capture()
});
}
if let CanvasTool::Message(_) = tool {
let first = state.source.is_none();
let (source, slot) = state.source.or_else(|| {
@@ -1897,6 +2146,48 @@ impl canvas::Program<Message> for Scene {
});
return Some(Action::request_redraw().and_capture());
}
if matches!(
context.representation,
scenario_layout::Representation::Saved { .. }
) {
let hit = self.hit_item(point)?;
let target = match hit.role {
SceneHitRole::LifelineHeader => {
Some(scenario::spacing::Target::Lane(hit.id))
}
SceneHitRole::MessageBody | SceneHitRole::MessageLabel => {
Some(scenario::spacing::Target::Message(hit.id))
}
_ => None,
};
if let Some((target, stamp)) = target.zip(self.spacing_stamp)
&& stamp.context == context
{
let token = Id::new_v4();
state.press = Some(SequencePress::Spacing { stamp, token });
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
scenario::spacing::Event::Begin {
stamp,
token,
target,
press_screen: cursor.position()?,
canvas_bounds: bounds,
scale: self.scale,
pan: self.camera_pan,
},
)))
.and_capture(),
);
}
// Saved spacing never silently changes the shared Scenario order.
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
context, hit.id,
)))
.and_capture(),
);
}
if let Some(occurrence) = self
.hit(point)
.filter(|id| self.rows.contains_key(id))
@@ -1926,6 +2217,22 @@ impl canvas::Program<Message> for Scene {
let press = state.press.take()?;
let context = context?;
match press {
SequencePress::Spacing { stamp, token } => {
state.source = None;
let event = if world.is_some() {
scenario::spacing::Event::Finish {
stamp,
token,
screen: cursor.position()?,
}
} else {
scenario::spacing::Event::Cancel { stamp, token }
};
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(event)))
.and_capture(),
);
}
SequencePress::Occurrence {
tool: captured,
slot,
@@ -2017,6 +2324,18 @@ impl canvas::Program<Message> for Scene {
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => {
if let Some(SequencePress::Spacing { stamp, token }) = state.press {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
scenario::spacing::Event::Move {
stamp,
token,
screen: cursor.position()?,
},
)))
.and_capture(),
);
}
Some(Action::request_redraw())
}
_ => None,
@@ -2198,7 +2517,10 @@ impl canvas::Program<Message> for Scene {
self.lifecycle
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if let Some((context, tool, editable)) = self.interaction {
let active = editable && state.context == Some(context) && state.tool == tool;
let active = editable
&& self.coordinate_input
&& state.context == Some(context)
&& state.tool == tool;
let point = cursor.position_in(bounds).map(|p| {
Point::new(
(p.x - self.camera_pan.x) / self.scale,
@@ -2214,7 +2536,12 @@ impl canvas::Program<Message> 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);
@@ -50,7 +50,7 @@ pub fn controls<'a>(
control("Details", "Details", Some(details)),
control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
text(format!("{:.0}%", scale * 100.)).size(12),
text(zoom_label(scale)).size(12),
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
control("ActualSize", "100%", Some(zoom(Some(1.)))),
]
@@ -65,6 +65,7 @@ pub fn controls<'a>(
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Scope {
pub(crate) scenario_view: Option<(super::scenario_layout::Representation, Id)>,
pub document: Id,
pub owner: Id,
pub revision: u64,
@@ -76,6 +77,7 @@ impl Scope {
Some(Self {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: app.camera_epoch,
})
@@ -84,6 +86,7 @@ impl Scope {
Some(Self {
document: context.document,
owner: context.owner,
scenario_view: None,
revision: context.revision,
epoch: app.camera_epoch,
})
@@ -103,8 +106,8 @@ impl Scope {
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
iced::advanced::widget::Id::from(format!(
"behavior-control:{}:{}:{}:{name}",
self.document, self.owner, self.revision
"behavior-control:{}:{}:{}:{:?}:{name}",
self.document, self.owner, self.revision, self.scenario_view
))
}
}
@@ -137,6 +140,20 @@ pub enum Event {
pub use focus::{focus_task, root as focus_root};
/// Fold relative input in event order. The base scale is used only on leaving Fit.
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
change_with_minimum(pan, zoom, fit, event, 0.05)
}
/// Scenario extents can require scales below the topology canvas's 5% floor.
pub(super) fn change_with_minimum(
pan: &mut Vector,
zoom: &mut Option<f32>,
fit: &mut bool,
event: Event,
minimum: f32,
) -> bool {
if !minimum.is_finite() || minimum <= 0. || minimum > 3. {
return false;
}
match event {
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
let next = *pan + delta;
@@ -156,7 +173,10 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: E
&& at.y.is_finite() =>
{
let old = if *fit { base } else { zoom.unwrap_or(base) };
let next = (old * factor).clamp(0.05, 3.);
if !old.is_finite() || old <= 0. {
return false;
}
let next = (old * factor).clamp(minimum, 3.);
if old == next {
return false;
}
@@ -174,6 +194,51 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: E
}
}
fn zoom_label(scale: f32) -> String {
let percent = f64::from(scale) * 100.;
if !percent.is_finite() || percent <= 0. {
return "—".into();
}
if percent >= 1. {
format!("{percent:.0}%")
} else if percent >= 0.01 {
format!("{percent:.3}%")
} else {
format!("{percent:.2e}%")
}
}
#[cfg(test)]
mod camera_policy_tests {
use super::*;
#[test]
fn topology_retains_five_percent_floor() {
let (mut pan, mut zoom, mut fit) = (Vector::ZERO, Some(0.06), false);
assert!(change(
&mut pan,
&mut zoom,
&mut fit,
Event::Zoom {
factor: 0.1,
at: Point::ORIGIN,
base: 0.06,
}
));
assert_eq!(zoom, Some(0.05));
}
#[test]
fn tiny_zoom_label_is_nonzero_and_changes_with_zoom() {
assert_eq!(zoom_label(1.), "100%");
assert_eq!(zoom_label(0.05), "5%");
assert_eq!(zoom_label(0.00034193), "0.034%");
assert_eq!(zoom_label(0.00034193 * 1.25), "0.043%");
assert_eq!(zoom_label(1e-7), "1.00e-5%");
assert_ne!(zoom_label(f32::from_bits(1)), "0%");
}
}
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
pub fn palette<'a>(
scope: Scope,
@@ -371,6 +371,7 @@ fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_input
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
use iced::widget::Space;
let scope = Scope {
scenario_view: None,
document: Id::new_v4(),
owner: Id::new_v4(),
revision: 2,
@@ -430,6 +431,7 @@ pub(crate) fn scope(app: &App) -> Scope {
Scope {
document: context.document,
owner: context.scenario,
scenario_view: Some((context.representation, context.visit)),
revision: context.revision,
epoch: app.camera_epoch,
}
@@ -438,6 +440,7 @@ pub(crate) fn scope(app: &App) -> Scope {
Scope {
document: context.document,
owner: context.owner,
scenario_view: None,
revision: context.revision,
epoch: app.camera_epoch,
}
@@ -893,11 +893,14 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
.add_element(Kind::Scenario, Layer::Oa, "Second scenario", None)
.unwrap();
let mut app = App::with_project(p.clone());
app.page = Page::Behavior;
app.layer = Layer::Oa;
app.behavior.active_owner = Some(a);
app.selected = a;
// Capture the context created by actual navigation, including its visit.
// Merely assigning active_owner/selected does not mount a Scenario anymore.
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(Page::Behavior));
let context = app.scenario_context().unwrap();
assert_eq!(context.scenario, a);
assert!(!context.visit.is_nil());
let snapshot = app.document.snapshot();
let source = app.document.bundle().clone();
let begin = |context| scenario::CanvasEvent::BeginOccurrence {
context,
@@ -908,23 +911,40 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
},
lifeline: None,
};
app.behavior.active_owner = Some(b);
app.selected = b;
let _ = app.update(Message::Select(b));
let second = app.scenario_context().unwrap();
assert_eq!(second.scenario, b);
assert_ne!(second.visit, context.visit);
let _ = app.update(Message::ScenarioCanvas(begin(context)));
assert!(!app.behavior.has_pending());
assert_eq!(app.project(), &p);
app.behavior.active_owner = Some(a);
app.selected = a;
app.layer = Layer::Sa;
let _ = app.update(Message::ScenarioCanvas(begin(context)));
let _ = app.update(Message::Select(a));
let before_layer_change = app.scenario_context().unwrap();
assert_eq!(before_layer_change.scenario, a);
assert_ne!(before_layer_change.visit, context.visit);
let _ = app.update(Message::Layer(Layer::Sa));
assert_eq!(app.layer, Layer::Sa);
assert!(app.scenario_context().is_none());
let _ = app.update(Message::ScenarioCanvas(begin(before_layer_change)));
assert!(!app.behavior.has_pending());
app.layer = Layer::Oa;
let _ = app.update(Message::Layer(Layer::Oa));
let _ = app.update(Message::Select(a));
let current = app.scenario_context().unwrap();
assert_eq!(current.scenario, a);
assert_ne!(current.visit, before_layer_change.visit);
// Returning to the same owner/layer must not revive either retired packet.
for retired in [context, before_layer_change] {
let _ = app.update(Message::ScenarioCanvas(begin(retired)));
assert!(!app.behavior.has_pending());
}
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
let _ = app.update(Message::ScenarioCanvas(begin(current)));
assert_eq!(app.behavior.pending_owner(), Some(a));
assert_eq!(app.project(), &p);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
+183 -22
View File
@@ -4,12 +4,28 @@ use super::topology::{occurrence, unique_batch};
use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
mod camera;
pub(crate) mod focus;
pub(crate) mod spacing;
use super::scenario_layout::{
Representation,
presentation::{self, ScenarioPresentation},
};
#[cfg(test)]
mod presentation_tests;
mod usage;
mod workspace;
pub(crate) use workspace::NAME_INPUT;
pub use workspace::{Shelf, view};
// A picker may select the Saved diagram record itself. Another view of the
// same Scenario is not the property target of this displayed representation.
pub(crate) fn properties_target(p: &Project, context: Context, id: Id) -> bool {
super::selected_behavior(p, id) == Some(context.scenario)
|| matches!(context.representation, Representation::Saved { diagram } if diagram == id)
&& presentation::checked_owner(p, context.representation) == Some(context.scenario)
}
#[cfg(test)]
#[path = "scenario_operation_tests.rs"]
mod operation_tests;
@@ -99,6 +115,8 @@ pub(crate) fn removal_target(p: &Project, owner: Id, id: Id) -> bool {
pub struct Context {
pub document: Id,
pub scenario: Id,
pub representation: Representation,
pub visit: Id,
pub revision: u64,
pub epoch: u64,
}
@@ -125,6 +143,7 @@ impl std::fmt::Display for CanvasTool {
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Spacing(spacing::Event),
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
Viewport(Context, Size),
@@ -132,9 +151,11 @@ pub enum CanvasEvent {
Zoom(Context, Option<f32>),
Camera(Context, u64, super::canvas_workspace::Event),
Shelf(Context, Shelf),
Properties(Context, Id),
OpenUse(Context, Id),
Back(Context),
Choose(Context, CanvasTool),
Representation(Context, Representation),
Select(Context, Id),
Remove(Context, Id),
Begin {
@@ -164,6 +185,7 @@ pub enum ScrollArea {
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Spacing(event) => event.stamp().context,
Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
@@ -171,9 +193,11 @@ impl CanvasEvent {
| Self::Zoom(context, _)
| Self::Camera(context, ..)
| Self::Shelf(context, _)
| Self::Properties(context, _)
| Self::OpenUse(context, _)
| Self::Back(context)
| Self::Choose(context, _)
| Self::Representation(context, _)
| Self::Select(context, _)
| Self::Remove(context, _)
| Self::Begin { context, .. }
@@ -231,6 +255,7 @@ pub enum ValueSection {
const VALUE_PAGE_SIZE: usize = 24;
#[derive(Default)]
pub struct State {
spacing: Option<spacing::controller::Session>,
draft: Option<Draft>,
form_session: Id,
form_mount: Id,
@@ -246,6 +271,7 @@ const CALLER_LIMIT: usize = 32;
pub(crate) struct Caller {
document: Id,
pub owner: Id,
pub representation: Representation,
pub occurrence: Id,
target: Id,
tool: CanvasTool,
@@ -255,6 +281,8 @@ pub(crate) struct Caller {
struct DetailsRestore {
document: Id,
owner: Id,
representation: Representation,
visit: Id,
token: Id,
offset: iced::Vector,
}
@@ -262,6 +290,8 @@ struct DetailsRestore {
pub(super) struct ScrollRestore {
pub document: Id,
pub owner: Id,
pub representation: Representation,
pub visit: Id,
pub token: Id,
pub offset: iced::Vector,
}
@@ -354,46 +384,158 @@ impl State {
}
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.presentation.identity != Some((document, owner)) {
self.enter_representation(document, owner, Representation::Derived { owner });
}
/// App validates requested identity with checked_owner before entering.
pub(crate) fn enter_representation(
&mut self,
document: Id,
owner: Id,
representation: Representation,
) -> bool {
if matches!(representation, Representation::Derived { owner: requested } if requested != owner)
{
return false;
}
let changed = self.presentation.identity != Some((document, owner))
|| self.presentation.representation != Some(representation);
if changed {
self.presentation = workspace::Presentation {
identity: Some((document, owner)),
representation: Some(representation),
fit: matches!(representation, Representation::Saved { .. }),
..Default::default()
};
self.details_offset = iced::Vector::ZERO;
self.restore = None;
self.canvas_tool = Some((owner, CanvasTool::Select));
}
if changed || self.presentation.visit.is_nil() {
self.spacing = None;
self.presentation.visit = Id::new_v4();
self.restore = None;
return true;
}
false
}
/// Suspend captured events while preserving this destination's camera state.
pub(crate) fn leave(&mut self) {
self.spacing = None;
self.presentation.visit = Id::nil();
self.restore = None;
}
pub(crate) fn forget_view(&mut self) {
self.spacing = None;
self.presentation.identity = None;
self.presentation.representation = None;
self.presentation.visit = Id::nil();
self.restore = None;
}
pub(crate) fn representation(&self) -> Option<Representation> {
self.presentation.representation
}
pub(crate) fn owner(&self) -> Option<Id> {
self.presentation.identity.map(|(_, owner)| owner)
}
pub(crate) fn context(
&self,
document: Id,
owner: Id,
revision: u64,
epoch: u64,
) -> Option<Context> {
if self.presentation.identity != Some((document, owner)) || self.presentation.visit.is_nil()
{
return None;
}
Some(Context {
document,
scenario: owner,
representation: self.representation()?,
visit: self.presentation.visit,
revision,
epoch,
})
}
pub(crate) fn display(
&self,
p: &Project,
owner: Id,
selected: Id,
) -> Option<ScenarioPresentation> {
let (_, current_owner) = self.presentation.identity?;
let representation = self.representation()?;
(current_owner == owner).then(|| presentation::present(p, representation, selected))
}
pub(crate) fn coordinate_input(&self, p: &Project, context: Context) -> bool {
self.context(
context.document,
context.scenario,
context.revision,
context.epoch,
) == Some(context)
&& self
.display(p, context.scenario, context.scenario)
.is_some_and(|shown| shown.capabilities().coordinate_input)
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
self.presentation.viewport(size)
}
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
pub(crate) fn camera(
&mut self,
project: &Project,
owner: Id,
event: super::canvas_workspace::Event,
) -> bool {
let Some(bounds) = self
.display(project, owner, owner)
.and_then(|shown| shown.bounds())
else {
return false;
};
let p = &mut self.presentation;
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
camera::change(
&mut p.pan,
&mut p.zoom,
&mut p.fit,
p.viewport,
bounds,
event,
)
}
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
self.presentation.zoom(scale)
}
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
let size = Scene::sequence(p, owner, owner).size;
let old = self.presentation.scale(size);
let Some(bounds) = self
.display(p, owner, owner)
.and_then(|shown| shown.bounds())
else {
return false;
};
if let Some(next) = zoom {
let current = self.presentation.camera(bounds);
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.camera(super::canvas_workspace::Event::Zoom {
factor: next / old,
at: Point::new(viewport.width / 2., viewport.height / 2.),
base: old,
})
self.camera(
p,
owner,
super::canvas_workspace::Event::Zoom {
factor: next / current.scale,
at: Point::new(viewport.width / 2., viewport.height / 2.),
base: current.scale,
},
)
} else {
self.zoom(None);
let fitted = self.presentation.scale(size);
self.zoom(Some(fitted));
self.presentation.pan = iced::Vector::ZERO;
true
self.zoom(None)
}
}
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
}
pub(crate) fn show_properties(&mut self) {
// Panels remain available. Dismiss the competing outline without
// replacing this view or its camera.
self.presentation.shelf = None;
}
pub(crate) fn open_use(
&mut self,
p: &Project,
@@ -410,6 +552,7 @@ impl State {
self.return_to.push(Caller {
document: context.document,
owner: context.scenario,
representation: context.representation,
occurrence,
target,
tool: self.canvas_tool(context.scenario),
@@ -424,6 +567,7 @@ impl State {
fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool {
frame.document == context.document
&& frame.target == context.scenario
&& presentation::checked_owner(p, frame.representation) == Some(frame.owner)
&& referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target)
}
pub(super) fn can_back(&self, p: &Project, context: Context) -> bool {
@@ -440,16 +584,23 @@ impl State {
None
}
pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) {
if context.document != frame.document || context.scenario != frame.owner {
if context.document != frame.document
|| context.scenario != frame.owner
|| context.representation != frame.representation
{
return;
}
self.canvas_tool = Some((frame.owner, frame.tool));
self.details_offset = frame.details_offset;
self.presentation = frame.presentation;
self.presentation.identity = Some((context.document, context.scenario));
self.presentation.representation = Some(frame.representation);
self.presentation.visit = context.visit;
self.restore = Some(DetailsRestore {
document: context.document,
owner: frame.owner,
representation: frame.representation,
visit: context.visit,
token: Id::new_v4(),
offset: frame.details_offset,
});
@@ -459,13 +610,17 @@ impl State {
context: Context,
ScrollArea::Page: ScrollArea,
) -> Option<ScrollRestore> {
let restore = self
.restore
.as_ref()
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
let restore = self.restore.as_ref().filter(|r| {
r.document == context.document
&& r.owner == context.scenario
&& r.representation == context.representation
&& r.visit == context.visit
})?;
Some(ScrollRestore {
document: restore.document,
owner: restore.owner,
representation: restore.representation,
visit: restore.visit,
token: restore.token,
offset: restore.offset,
})
@@ -478,7 +633,11 @@ impl State {
offset: iced::Vector,
) -> bool {
if !self.restore.as_ref().is_some_and(|r| {
r.document == context.document && r.owner == context.scenario && r.token == token
r.document == context.document
&& r.owner == context.scenario
&& r.representation == context.representation
&& r.visit == context.visit
&& r.token == token
}) {
return false;
}
@@ -1951,6 +2110,8 @@ pub(crate) mod direct_tests {
Context {
document: Id::new_v4(),
scenario: owner,
representation: Representation::Derived { owner },
visit: Id::new_v4(),
revision: 0,
epoch: 0,
}
@@ -0,0 +1,284 @@
//! Scenario camera policy. Bounds are from the exact displayed presentation,
//! including its origin and automatic fallback; no model geometry is authored.
use crate::behavior::canvas_workspace::{self, Event};
use iced::{Rectangle, Size, Vector};
pub(super) const MIN_SCALE: f32 = f32::from_bits(1);
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Camera {
pub(super) scale: f32,
pub(super) pan: Vector,
}
/// Fit never enlarges above 100%. Use f64 intermediates so a valid f32 origin
/// and size cannot overflow while finding the center. Unrepresentable cameras
/// are refused rather than silently clamped to an unrelated minimum scale.
pub(super) fn fit(bounds: Rectangle, viewport: Size) -> Option<Camera> {
if [
bounds.x,
bounds.y,
bounds.width,
bounds.height,
viewport.width,
viewport.height,
]
.iter()
.any(|n| !n.is_finite())
|| bounds.width < 0.
|| bounds.height < 0.
|| viewport.width <= 0.
|| viewport.height <= 0.
{
return None;
}
let width = f64::from(viewport.width);
let height = f64::from(viewport.height);
let scale = ((width - 2. * (width / 4.).min(8.)) / f64::from(bounds.width).max(1.))
.min((height - 2. * (height / 4.).min(8.)) / f64::from(bounds.height).max(1.))
.min(1.) as f32;
if !scale.is_finite() || scale <= 0. {
return None;
}
let x = width / 2. - (f64::from(bounds.x) + f64::from(bounds.width) / 2.) * f64::from(scale);
let y = height / 2. - (f64::from(bounds.y) + f64::from(bounds.height) / 2.) * f64::from(scale);
let pan = Vector::new(x as f32, y as f32);
(pan.x.is_finite() && pan.y.is_finite()).then_some(Camera { scale, pan })
}
pub(super) fn effective_camera(
pan: Vector,
zoom: Option<f32>,
fitting: bool,
viewport: Option<Size>,
bounds: Rectangle,
) -> Camera {
if fitting && let Some(camera) = viewport.and_then(|size| fit(bounds, size)) {
return camera;
}
Camera {
scale: zoom.unwrap_or(1.),
pan,
}
}
/// None preserves Fit as a mode, including before the first viewport arrives.
pub(super) fn set_zoom(
pan: &mut Vector,
zoom: &mut Option<f32>,
fitting: &mut bool,
scale: Option<f32>,
) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.min(3.));
let next_pan = if scale.is_none() { Vector::ZERO } else { *pan };
let changed = *zoom != next || *fitting != scale.is_none() || *pan != next_pan;
*zoom = next;
*fitting = scale.is_none();
*pan = next_pan;
changed
}
/// Materialize the visible fitted camera only when a camera gesture succeeds.
/// A zero-motion or rejected event preserves the original mode and fields.
pub(super) fn change(
pan: &mut Vector,
zoom: &mut Option<f32>,
fitting: &mut bool,
viewport: Option<Size>,
bounds: Rectangle,
event: Event,
) -> bool {
let camera = effective_camera(*pan, *zoom, *fitting, viewport, bounds);
let (mut next_pan, mut next_zoom, mut next_fit) = (camera.pan, Some(camera.scale), false);
let event = match event {
Event::Zoom { base, .. } if !base.is_finite() || base <= 0. => return false,
Event::Zoom { factor, at, .. } => Event::Zoom {
factor,
at,
base: camera.scale,
},
event => event,
};
if !canvas_workspace::change_with_minimum(
&mut next_pan,
&mut next_zoom,
&mut next_fit,
event,
MIN_SCALE,
) {
return false;
}
*pan = next_pan;
*zoom = next_zoom;
*fitting = next_fit;
true
}
#[cfg(test)]
mod tests {
use super::*;
use iced::Point;
fn bounds() -> Rectangle {
Rectangle {
x: -1_000_160.,
y: -200.,
width: 2_000_400.,
height: 1_000.,
}
}
fn near(actual: f32, expected: f32, tolerance: f32) {
assert!(
(actual - expected).abs() <= tolerance,
"{actual} != {expected}"
);
}
fn projected(camera: Camera, point: Point) -> Point {
Point::new(
point.x * camera.scale + camera.pan.x,
point.y * camera.scale + camera.pan.y,
)
}
#[test]
fn negative_origin_fit_contains_independently_known_extremes() {
let camera = fit(bounds(), Size::new(700., 500.)).unwrap();
near(camera.scale, 0.0003419316, 1e-10);
near(camera.pan.x, 349.98633, 0.0001);
near(
projected(camera, Point::new(-1_000_160., -200.)).x,
8.,
0.0001,
);
near(
projected(camera, Point::new(1_000_240., 800.)).x,
692.,
0.0001,
);
near(projected(camera, Point::new(40., 300.)).y, 250., 0.0001);
}
#[test]
fn fit_remains_active_across_first_and_changed_viewport() {
let (mut pan, mut zoom, mut fitting) = (Vector::new(50., 80.), Some(0.8), false);
assert!(set_zoom(&mut pan, &mut zoom, &mut fitting, None));
let retained = effective_camera(pan, zoom, fitting, Some(Size::new(1200., 500.)), bounds());
let actual = effective_camera(pan, zoom, fitting, Some(Size::new(500., 500.)), bounds());
assert!(actual.scale < retained.scale);
near(
projected(actual, Point::new(-1_000_160., -200.)).x,
8.,
0.0001,
);
near(
projected(actual, Point::new(1_000_240., 800.)).x,
492.,
0.0001,
);
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
#[test]
fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let start = fit(bounds(), size).unwrap();
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
let at = Point::new(210., 180.);
let world = Point::new(
(at.x - start.pan.x) / start.scale,
(at.y - start.pan.y) / start.scale,
);
for factor in [1.25, 0.8] {
let prior_scale = zoom.unwrap_or(start.scale);
assert!(change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
Event::Zoom {
factor,
at,
base: start.scale,
}
));
let camera = effective_camera(pan, zoom, fitting, viewport, bounds());
near(camera.scale, prior_scale * factor, 1e-10);
assert!(camera.scale < 0.001);
near(projected(camera, world).x, at.x, 0.0001);
near(projected(camera, world).y, at.y, 0.0001);
}
near(zoom.unwrap(), start.scale, 1e-10);
near(pan.x, start.pan.x, 0.0001);
assert!(!fitting);
}
#[test]
fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
for event in [
Event::Pan(Vector::ZERO),
Event::Zoom {
factor: 1.,
at: Point::ORIGIN,
base: 1.,
},
] {
assert!(!change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
event
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
let start = fit(bounds(), size).unwrap();
assert!(change(
&mut pan,
&mut zoom,
&mut fitting,
viewport,
bounds(),
Event::Pan(Vector::new(20., -30.))
));
assert_eq!(pan, start.pan + Vector::new(20., -30.));
assert_eq!(zoom, Some(start.scale));
assert!(!fitting);
}
#[test]
fn invalid_inputs_are_passive_and_large_finite_bounds_do_not_overflow_center() {
assert!(fit(bounds(), Size::new(f32::NAN, 500.)).is_none());
let extreme = Rectangle {
x: f32::MAX,
y: 0.,
width: f32::MAX,
height: 100.,
};
let camera = fit(extreme, Size::new(700., 500.)).unwrap();
assert!(camera.scale > 0. && camera.scale < 1e-30 && camera.pan.x.is_finite());
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
assert!(!set_zoom(&mut pan, &mut zoom, &mut fitting, Some(f32::NAN)));
assert!(!change(
&mut pan,
&mut zoom,
&mut fitting,
Some(Size::new(700., 500.)),
bounds(),
Event::Zoom {
factor: 1.25,
at: Point::ORIGIN,
base: f32::NAN
}
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
}
@@ -11,6 +11,8 @@ use std::collections::BTreeMap;
pub(crate) struct Stamp {
document: ModelId,
owner: ModelId,
representation: super::Representation,
visit: ModelId,
revision: u64,
session: ModelId,
mount: ModelId,
@@ -20,6 +22,8 @@ impl Stamp {
Self {
document: context.document,
owner: context.scenario,
representation: context.representation,
visit: context.visit,
revision: context.revision,
session,
mount,
@@ -28,10 +32,12 @@ impl Stamp {
}
pub(crate) fn name_id(stamp: Stamp) -> Id {
Id::from(format!(
"{}:{}:{}:{}:{}:{}",
"{}:{}:{}:{:?}:{}:{}:{}:{}",
super::NAME_INPUT,
stamp.document,
stamp.owner,
stamp.representation,
stamp.visit,
stamp.revision,
stamp.session,
stamp.mount
@@ -0,0 +1,348 @@
//! Controller identity and actual presentation input, independent of App routing.
use super::*;
use oa_core::{Diagram, DiagramLayout, ProjectionKind, ScenarioLanePosition, ScenarioLayout};
fn saved(p: &mut Project, owner: Id, lanes: &[(Id, f32)]) -> Representation {
let id = Id::new_v4();
p.diagrams.insert(
id,
Diagram {
id,
alias: format!("saved_{}", p.diagrams.len()),
name: "Saved scenario".into(),
description: String::new(),
layer: p.elements[&owner].layer(),
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: lanes
.iter()
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
.collect(),
messages: vec![],
})),
},
);
let errors: Vec<_> = oa_core::validation::validate(p)
.into_iter()
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
Representation::Saved { diagram: id }
}
#[test]
fn saved_visits_distinguish_same_owner_roundtrips_and_suspended_return_without_editing() {
let mut f = direct_tests::fixture();
let a = saved(&mut f.project, f.owner, &[(f.sender, -500.)]);
let b = saved(&mut f.project, f.owner, &[(f.sender, 900.)]);
let original = f.project.clone();
let document = Id::new_v4();
let mut state = State::default();
assert!(state.enter_representation(document, f.owner, a));
let first = state.context(document, f.owner, 7, 9).unwrap();
assert!(!state.enter_representation(document, f.owner, a));
assert_eq!(state.context(document, f.owner, 7, 9), Some(first));
state.presentation.pan = iced::Vector::new(23., -17.);
state.leave();
assert_eq!(state.representation(), Some(a));
assert_eq!(state.owner(), Some(f.owner));
assert!(state.context(document, f.owner, 7, 9).is_none());
assert!(state.enter_representation(document, f.owner, a));
let returned = state.context(document, f.owner, 7, 9).unwrap();
assert_ne!(returned.visit, first.visit);
assert_eq!(state.presentation.pan, iced::Vector::new(23., -17.));
assert!(state.enter_representation(document, f.owner, b));
let middle = state.context(document, f.owner, 7, 9).unwrap();
assert!(state.enter_representation(document, f.owner, a));
let last = state.context(document, f.owner, 7, 9).unwrap();
assert_ne!(last.visit, returned.visit);
assert_ne!(last.visit, middle.visit);
assert!(!state.coordinate_input(&f.project, first));
assert!(state.coordinate_input(&f.project, last));
let session = Id::new_v4();
let mount = Id::new_v4();
assert_ne!(
focus::Stamp::new(first, session, mount),
focus::Stamp::new(last, session, mount)
);
assert_eq!(f.project, original);
state.forget_view();
assert_eq!(state.representation(), None);
assert_eq!(state.owner(), None);
assert!(state.context(document, f.owner, 7, 9).is_none());
}
#[test]
fn saved_caller_restores_exact_representation_with_fresh_visit_and_rejects_deleted_view() {
let (mut project, [a, b, _], [reference, _, _]) = direct_tests::navigation_fixture();
let view = saved(&mut project, a, &[]);
let original = project.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter_representation(document, a, view);
let initial = state.context(document, a, 0, 0).unwrap();
state.presentation.pan = iced::Vector::new(11., 29.);
state.details_offset = iced::Vector::new(0., 71.);
assert_eq!(state.open_use(&project, initial, reference).unwrap(), b);
state.enter(document, b);
let callee = state.context(document, b, 0, 0).unwrap();
let frame = state.back(&project, callee).unwrap();
assert_eq!(frame.representation, view);
state.enter_representation(document, a, frame.representation);
let returned = state.context(document, a, 0, 0).unwrap();
state.restore_caller(returned, frame);
assert_ne!(returned.visit, initial.visit);
assert_eq!(state.representation(), Some(view));
assert_eq!(state.presentation.pan, iced::Vector::new(11., 29.));
assert!(state.scroll_restore(initial, ScrollArea::Page).is_none());
assert_eq!(
state
.scroll_restore(returned, ScrollArea::Page)
.unwrap()
.offset,
iced::Vector::new(0., 71.)
);
assert_eq!(project, original);
state.open_use(&project, returned, reference).unwrap();
state.enter(document, b);
let Representation::Saved { diagram } = view else {
unreachable!()
};
project.diagrams.remove(&diagram);
let callee = state.context(document, b, 0, 0).unwrap();
assert!(state.back(&project, callee).is_none());
}
#[test]
fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestures() {
let mut f = direct_tests::fixture();
// Equal authored lane centers are structurally valid, but cannot render a distinct-endpoint call.
let rep = saved(
&mut f.project,
f.owner,
&[(f.sender, 900.), (f.receiver, 900.)],
);
let original = f.project.clone();
let mut state = State::default();
state.enter_representation(Id::new_v4(), f.owner, rep);
let document = state.presentation.identity.unwrap().0;
let context = state.context(document, f.owner, 0, 0).unwrap();
let shown = state.display(&f.project, f.owner, f.call).unwrap();
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Fallback { .. }
));
assert!(!state.coordinate_input(&f.project, context));
let automatic = presentation::present(
&f.project,
Representation::Derived { owner: f.owner },
f.call,
);
let automatic = automatic.scene().unwrap();
assert_eq!(shown.scene().unwrap().render_lanes, automatic.render_lanes);
assert_eq!(shown.scene().unwrap().activations, automatic.activations);
assert!(!automatic.activations.is_empty());
let mut scene = shown.into_scene().unwrap();
scene.coordinate_input = false;
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
let point = Point::new(
(line.points[0].x + line.points.last().unwrap().x) / 2.,
line.points[0].y,
);
assert_eq!(scene.hit(point), Some(f.call));
let bounds = Rectangle::with_size(Size::new(1600., 1000.));
for tool in [
CanvasTool::Select,
CanvasTool::Message(MessageStyle::CallReply),
CanvasTool::Add(Tool::Reuse),
] {
scene.interaction = Some((context, tool, true));
let mut input = SequenceInput::default();
let mut messages = vec![];
for event in [
mouse::Event::CursorMoved { position: point },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved {
position: Point::new(point.x, point.y + 100.),
},
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
if let Some(message) = canvas::Program::update(
&scene,
&mut input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(point),
)
.and_then(|action| action.into_inner().0)
{
messages.push(message);
}
}
assert_eq!(messages.len(), 1);
assert!(
matches!(messages[0], Message::ScenarioCanvas(CanvasEvent::Select(c, id)) if c == context && id == f.call)
);
assert!(input.press.is_none() && input.source.is_none());
}
// Capture from an actual Ready presentation, then revoke coordinate input
// with the complete fallback frame of the same Saved representation.
let Representation::Saved { diagram } = rep else {
unreachable!()
};
let mut ready_project = f.project.clone();
ready_project.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let ready_original = ready_project.clone();
let ready = state.display(&ready_project, f.owner, f.call).unwrap();
assert!(matches!(
ready.status(),
presentation::PresentationStatus::Ready
));
let mut ready_scene = ready.into_scene().unwrap();
let line = ready_scene
.lines
.iter()
.find(|line| line.id == f.call)
.unwrap();
let ready_point = Point::new(
(line.points[0].x + line.points.last().unwrap().x) / 2.,
line.points[0].y,
);
let hit = ready_scene.hit_item(ready_point).unwrap();
assert_eq!(hit.id, f.call);
assert_eq!(hit.role, SceneHitRole::MessageBody);
let stamp = spacing::Stamp {
context,
camera_epoch: 17,
};
ready_scene.coordinate_input = true;
ready_scene.interaction = Some((context, CanvasTool::Select, true));
ready_scene.spacing_stamp = Some(stamp);
let mut input = SequenceInput::default();
let begin = canvas::Program::update(
&ready_scene,
&mut input,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.unwrap();
let Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin {
stamp: captured_stamp,
token,
target: spacing::Target::Message(target),
..
})) = begin
else {
panic!("Ready Saved message must begin a stamped spacing gesture");
};
assert_eq!(captured_stamp, stamp);
assert_eq!(target, f.call);
assert!(matches!(input.press, Some(SequencePress::Spacing {
stamp: held_stamp, token: held_token
}) if held_stamp == stamp && held_token == token));
scene.interaction = Some((context, CanvasTool::Select, true));
assert!(!scene.coordinate_input && scene.spacing_stamp.is_none());
let release = canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
let result = canvas::Program::update(
&scene,
&mut input,
&release,
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0);
assert!(
matches!(result, Some(Message::ScenarioCanvas(CanvasEvent::Spacing(
spacing::Event::Cancel { stamp: cancelled_stamp, token: cancelled_token }
))) if cancelled_stamp == stamp && cancelled_token == token)
);
assert!(input.press.is_none() && input.source.is_none());
assert!(
canvas::Program::update(
&scene,
&mut input,
&release,
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.is_none()
);
assert_eq!(ready_project, ready_original);
assert_eq!(f.project, original);
}
#[test]
fn saved_camera_fits_actual_negative_origin_and_tiny_scale_without_authored_mutation() {
let mut f = direct_tests::fixture();
let rep = saved(
&mut f.project,
f.owner,
&[(f.sender, -1_000_000.), (f.receiver, 1_000_000.)],
);
let original = f.project.clone();
let mut state = State::default();
state.enter_representation(Id::new_v4(), f.owner, rep);
state.viewport(Size::new(700., 500.));
assert!(
state.presentation.fit,
"new saved views await their real viewport in Fit mode"
);
assert!(
!state.camera_zoom(&f.project, f.owner, None),
"already-active Fit is idempotent"
);
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Ready
));
let bounds = shown.bounds().unwrap();
assert!(bounds.x < -1_000_000. && bounds.x + bounds.width > 1_000_000.);
let fitted = state.presentation.camera(bounds);
assert!(fitted.scale > 0. && fitted.scale < 0.001);
let left = bounds.x * fitted.scale + fitted.pan.x;
let right = (bounds.x + bounds.width) * fitted.scale + fitted.pan.x;
assert!((left - 8.).abs() < 0.001 && (right - 692.).abs() < 0.001);
assert!(state.camera(
&f.project,
f.owner,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(13., -7.))
));
let moved = state.presentation.camera(bounds);
assert_eq!(moved.scale, fitted.scale);
assert_eq!(moved.pan, fitted.pan + iced::Vector::new(13., -7.));
assert!(!state.presentation.fit);
assert_eq!(f.project, original);
}
#[test]
fn deleted_saved_identity_remains_unavailable_and_never_falls_into_derived_geometry() {
let mut f = direct_tests::fixture();
let rep = saved(&mut f.project, f.owner, &[]);
let mut state = State::default();
let document = Id::new_v4();
state.enter_representation(document, f.owner, rep);
let Representation::Saved { diagram } = rep else {
unreachable!()
};
f.project.diagrams.remove(&diagram);
let original = f.project.clone();
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
assert_eq!(shown.requested(), rep);
assert!(matches!(
shown.status(),
presentation::PresentationStatus::Unavailable { .. }
));
assert!(shown.scene().is_none() && shown.bounds().is_none());
assert_eq!(state.owner(), Some(f.owner));
assert!(state.context(document, f.owner, 0, 0).is_some());
assert!(!state.camera_zoom(&f.project, f.owner, None));
assert_eq!(f.project, original);
}
@@ -0,0 +1,160 @@
//! Pure Saved-only spacing preparation. Delta is measured from the original
//! pointer in the captured camera's world coordinates, never from the last
//! preview. The controller must own document/revision/visit, camera and hit-role
//! admission; this module neither dispatches commands nor changes selection.
use crate::behavior::scenario_layout::{
GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve,
};
use oa_core::{
Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition,
ScenarioMessagePosition, SharedBody, execute_batch,
};
pub(crate) mod controller;
#[cfg(test)]
mod tests;
pub(crate) use controller::{Event, Stamp};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
Lane(Id),
Message(Id),
}
impl Target {
pub(crate) fn id(self) -> Id {
match self {
Self::Lane(id) | Self::Message(id) => id,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Refusal {
InvalidSavedView(Id),
TargetNotRendered(Target),
InvalidDelta,
InvalidGesture,
CameraChanged,
TargetNotHit(Target),
Command(String),
Geometry(Vec<GeometryIssue>),
}
pub(super) struct Drag {
original: Project,
diagram: Id,
target: Target,
origin: f32,
original_geometry: ResolvedScenarioPlan,
}
pub(super) struct Preview {
pub(super) command: Command,
#[cfg(test)]
pub(super) project: Project,
pub(super) geometry: ResolvedScenarioPlan,
}
/// Capture an accepted, actually drawn target. A Saved view displaying automatic
/// fallback is not an editable original, even if the target appears in fallback.
pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<Drag, Refusal> {
let representation = Representation::Saved { diagram };
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
let element = project.elements.get(&target.id());
let origin = match target {
Target::Lane(id)
if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) =>
{
// Unassigned lifelines still have drawn headers. `scene.lanes`
// excludes them only from semantic message-endpoint hit testing.
geometry.scene.render_lanes.get(&id).copied()
}
Target::Message(id)
if element.is_some_and(|e| {
matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
)
}) =>
{
// A lifecycle row can denote receive y; the public position means
// send y. Read the actual outgoing endpoint, including self calls.
geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.and_then(|line| line.points.first())
.map(|point| point.y)
}
_ => None,
}
.ok_or(Refusal::TargetNotRendered(target))?;
if !origin.is_finite() {
return Err(Refusal::TargetNotRendered(target));
}
Ok(Drag {
original: project.clone(),
diagram,
target,
origin,
original_geometry: geometry,
})
}
impl Drag {
/// None means there is no command to commit. In particular +0/-0, return to
/// start, and sub-precision motion never materialize a missing override or
/// normalize an existing explicit value. Document/redo are never touched.
pub(super) fn prepare(&self, original_delta: f32) -> Result<Option<Preview>, Refusal> {
if !original_delta.is_finite() {
return Err(Refusal::InvalidDelta);
}
if original_delta == 0. {
return Ok(None);
}
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
if position == self.origin {
return Ok(None);
}
let command = match self.target {
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
diagram: self.diagram,
position: ScenarioLanePosition {
lifeline,
x: position,
},
},
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
diagram: self.diagram,
position: ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence,
},
send_y: position,
},
},
};
let project = execute_batch(&self.original, std::slice::from_ref(&command))
.map_err(|error| Refusal::Command(error.to_string()))?;
let geometry = resolve(
&project,
Representation::Saved {
diagram: self.diagram,
},
self.target.id(),
)
.map_err(Refusal::Geometry)?;
Ok(Some(Preview {
command,
#[cfg(test)]
project,
geometry,
}))
}
}
@@ -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<Preview>,
refusal: Option<Refusal>,
ever_crossed: bool,
}
pub(in crate::behavior) struct Paint<'a> {
pub(in crate::behavior) scene: &'a Scene,
#[cfg(test)]
pub(in crate::behavior) bounds: Rectangle,
pub(in crate::behavior) refusal: Option<&'a Refusal>,
pub(in crate::behavior) original_diagnostics: &'a [Diagnostic],
pub(in crate::behavior) original_latent: &'a [Id],
pub(in crate::behavior) diagnostics: &'a [Diagnostic],
}
pub(crate) enum Finish {
Click(Id),
Noop,
Commit {
command: Box<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<Id> {
self.spacing.as_ref().map(|s| s.token)
}
pub(crate) fn spacing_stamp(&self) -> Option<Stamp> {
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<Paint<'_>> {
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<Finish, Refusal> {
let Some(mut session) = self.spacing.take() else {
return Err(Refusal::InvalidGesture);
};
session.update(screen);
if let Some(error) = session.refusal {
return Err(error);
}
if !session.ever_crossed {
return Ok(Finish::Click(session.drag.target.id()));
}
Ok(match session.preview {
None => Finish::Noop,
Some(preview) => Finish::Commit {
command: Box::new(preview.command),
selected: session.drag.target.id(),
scale: session.scale,
pan: session.pan,
},
})
}
pub(crate) fn accept_spacing_camera(&mut self, scale: f32, pan: Vector) {
self.presentation.zoom = Some(scale);
self.presentation.pan = pan;
self.presentation.fit = false;
}
}
@@ -0,0 +1,421 @@
use super::*;
use crate::document::Document;
use iced::Size;
use oa_core::{Diagram, Layer, ProjectionKind, language, source_project::ProjectLimits};
pub(crate) fn fixture() -> (Document, Id, Id, Id, Id) {
let mut p = language::parse(r#"arcadia 7;
project spacing "Spacing" {
oa_activity work "Work" {}
oa_activity response "Response" {}
oa_scenario incident "Incident" { viewpoint OperationalActivity; steps [@first, @second]; }
oa_lifeline reporter "Reporter" { parent @incident; represented @work; }
oa_lifeline operator "Operator" { parent @incident; represented @response; }
oa_occurrence first "First" { parent @incident; body { Exchange { exchange none; sender @reporter; receiver @operator; message_kind Asynchronous; }; }; }
oa_occurrence second "Second" { parent @incident; body { Exchange { exchange none; sender @operator; receiver @reporter; message_kind Asynchronous; }; }; }
}"#).unwrap();
let id = |name: &str| {
p.identities()
.into_iter()
.find(|(_, alias)| *alias == name)
.unwrap()
.0
};
let (owner, lane, message) = (id("incident"), id("reporter"), id("first"));
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "saved".into(),
name: "Saved".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
nodes: Default::default(),
routes: vec![],
view_options: Default::default(),
layout: None,
},
);
(
Document::from_project(p, ProjectLimits::default()).unwrap(),
owner,
diagram,
lane,
message,
)
}
pub(crate) fn expected(p: &Project, diagram: Id, target: Target, delta: f32) -> Project {
capture(p, diagram, target)
.unwrap()
.prepare(delta)
.unwrap()
.unwrap()
.project
}
pub(crate) fn packet(
p: &Project,
state: &State,
context: Context,
camera_epoch: u64,
target: Target,
) -> Event {
let geometry = resolve(p, context.representation, target.id()).unwrap();
let world = match target {
Target::Lane(id) => geometry
.scene
.boxes
.iter()
.find(|item| item.id == id && item.role == SceneHitRole::LifelineHeader)
.unwrap()
.rect
.center(),
Target::Message(id) => {
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap();
Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y)
}
};
assert!(
geometry
.scene
.hit_item(world)
.is_some_and(|hit| hit.id == target.id())
);
let camera = state.presentation.camera(geometry.bounds);
let canvas_bounds = Rectangle::new(Point::new(17., 23.), state.presentation.viewport.unwrap());
Event::Begin {
stamp: Stamp {
context,
camera_epoch,
},
token: Id::new_v4(),
target,
press_screen: Point::new(
canvas_bounds.x + world.x * camera.scale + camera.pan.x,
canvas_bounds.y + world.y * camera.scale + camera.pan.y,
),
canvas_bounds,
scale: camera.scale,
pan: camera.pan,
}
}
fn setup(target_message: bool) -> (Document, State, Event) {
let (doc, owner, diagram, lane, message) = fixture();
let mut state = State::default();
state.enter_representation(Id::new_v4(), owner, Representation::Saved { diagram });
state.presentation.viewport = Some(Size::new(900., 600.));
let context = state
.context(
state.presentation.identity.unwrap().0,
owner,
doc.revision(),
4,
)
.unwrap();
let target = if target_message {
Target::Message(message)
} else {
Target::Lane(lane)
};
let event = packet(doc.project(), &state, context, 9, target);
(doc, state, event)
}
fn press(event: Event) -> Point {
let Event::Begin { press_screen, .. } = event else {
unreachable!()
};
press_screen
}
#[test]
fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() {
for message in [false, true] {
let (doc, mut state, begin) = setup(message);
let original = doc.snapshot();
let camera = state.camera_boundary();
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
let first = if message {
Vector::new(500., 30.)
} else {
Vector::new(30., 500.)
};
state.move_spacing(start + first);
let session = state.spacing.as_ref().unwrap();
let candidate = session.preview.as_ref().unwrap();
let delta = 30. / session.scale;
let actual_anchor = match session.drag.target {
Target::Lane(id) => candidate.geometry.scene.render_lanes[&id],
Target::Message(id) => {
candidate
.geometry
.scene
.lines
.iter()
.find(|line| line.id == id)
.unwrap()
.points[0]
.y
}
};
let wanted = (f64::from(session.drag.origin) + f64::from(delta)) as f32;
assert_eq!(actual_anchor.to_bits(), wanted.to_bits());
let expected = session.drag.prepare(delta).unwrap().unwrap();
assert_eq!(candidate.project, expected.project);
assert_eq!(candidate.geometry.scene.rows, expected.geometry.scene.rows);
assert_eq!(
candidate.geometry.scene.render_lanes,
expected.geometry.scene.render_lanes
);
state.move_spacing(start + first * 2.);
let session = state.spacing.as_ref().unwrap();
assert_eq!(
session.preview.as_ref().unwrap().project,
session.drag.prepare(delta * 2.).unwrap().unwrap().project
);
assert_eq!(doc.project(), &original.decoded.project);
assert_eq!(doc.compiled().bundle, original.bundle);
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
assert!(camera == state.camera_boundary());
}
}
#[test]
fn click_threshold_is_active_axis_and_return_to_zero_is_not_a_click() {
let (doc, mut state, begin) = setup(false);
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(2., 100.));
assert!(matches!(
state.finish_spacing(start).unwrap(),
Finish::Click(_)
));
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(4., 0.));
assert!(matches!(state.finish_spacing(start).unwrap(), Finish::Noop));
assert!(!doc.can_undo());
}
#[test]
fn invalid_move_discards_prior_candidate_and_finish_rechecks_final_pointer() {
let (doc, mut state, begin) = setup(false);
let start = press(begin);
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(start + Vector::new(20., 0.));
state.move_spacing(Point::new(f32::NAN, start.y));
let paint = state.spacing_paint(begin.stamp().context, 9).unwrap();
assert!(matches!(paint.refusal, Some(Refusal::InvalidDelta)));
assert_eq!(
paint.scene.render_lanes,
state
.spacing
.as_ref()
.unwrap()
.drag
.original_geometry
.scene
.render_lanes
);
assert!(matches!(
state.finish_spacing(Point::new(f32::NAN, start.y)),
Err(Refusal::InvalidDelta)
));
assert!(state.spacing_token().is_none());
}
#[test]
fn forged_camera_hit_role_and_retired_visit_cannot_capture() {
let (doc, mut state, begin) = setup(false);
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = begin
else {
unreachable!()
};
assert!(matches!(
state.begin_spacing(
doc.project(),
Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale: scale * 2.,
pan
}
),
Err(Refusal::CameraChanged)
));
assert!(matches!(
state.begin_spacing(
doc.project(),
Event::Begin {
stamp,
token,
target,
press_screen: canvas_bounds.center(),
canvas_bounds,
scale,
pan
}
),
Err(Refusal::TargetNotHit(_))
));
state.leave();
assert!(matches!(
state.begin_spacing(doc.project(), begin),
Err(Refusal::InvalidGesture)
));
}
#[test]
fn zoomed_out_message_tolerance_matches_actual_canvas_hit() {
let (doc, mut state, _) = setup(true);
state.presentation.fit = false;
state.presentation.zoom = Some(0.25);
state.presentation.pan = Vector::ZERO;
let context = state
.context(
state.presentation.identity.unwrap().0,
state.owner().unwrap(),
doc.revision(),
4,
)
.unwrap();
let message = doc
.project()
.identities()
.into_iter()
.find(|(_, alias)| *alias == "first")
.map(|(id, _)| id)
.unwrap();
let Event::Begin {
stamp,
token,
target,
press_screen,
canvas_bounds,
scale,
pan,
} = packet(doc.project(), &state, context, 9, Target::Message(message))
else {
unreachable!()
};
let event = Event::Begin {
stamp,
token,
target,
press_screen: press_screen + Vector::new(0., 3.),
canvas_bounds,
scale,
pan,
};
state.begin_spacing(doc.project(), event).unwrap();
assert_eq!(state.spacing_token(), Some(token));
}
#[test]
fn fractional_screen_packet_preserves_subpixel_motion_in_preview_and_finish() {
let (doc, owner, diagram, _, message) = fixture();
let original = doc.snapshot();
assert!(doc.project().diagrams[&diagram].layout.is_none());
let mut state = State::default();
let document = Id::new_v4();
state.enter_representation(document, owner, Representation::Saved { diagram });
state.presentation.viewport = Some(Size::new(900., 600.));
state.presentation.fit = false;
state.presentation.zoom = Some(0.5);
// 406 + 2^-15 puts the press just below the 512 screen-coordinate boundary.
state.presentation.pan = Vector::new(0., f32::from_bits(0x43cb_0001));
let camera = state.camera_boundary();
let context = state.context(document, owner, doc.revision(), 4).unwrap();
let baseline = resolve(doc.project(), context.representation, message).unwrap();
let line = baseline
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert_eq!(line.points[0].y, 148.);
let begin = packet(doc.project(), &state, context, 9, Target::Message(message));
let Event::Begin {
press_screen,
canvas_bounds,
scale,
pan,
..
} = begin
else {
unreachable!()
};
assert_eq!(canvas_bounds.y, 23.);
assert_eq!(scale, 0.5);
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
assert_eq!(press_screen.y.to_bits(), 0x43fb_8001); // 503 + 2^-15.
let end = press_screen + Vector::new(0., 10.);
assert_eq!(end.y, 513.);
assert!(canvas_bounds.contains(press_screen) && canvas_bounds.contains(end));
// The submitted f32 displacement is 10 - 2^-15, not exactly 10.
assert_eq!((end.y - press_screen.y).to_bits(), 0x411f_ffe0);
assert_eq!(((end.y - press_screen.y) / scale).to_bits(), 0x419f_ffe0);
// Independent oracle: 148 + (10 - 2^-15) / .5 = 168 - 2^-14.
let expected_y = f32::from_bits(0x4327_fffc);
let mut expected = original.decoded.project.clone();
expected.diagrams.get_mut(&diagram).unwrap().layout =
Some(oa_core::DiagramLayout::Scenario(oa_core::ScenarioLayout {
lanes: vec![],
messages: vec![oa_core::ScenarioMessagePosition {
address: oa_core::OccurrenceEndpoint {
references: vec![],
occurrence: message,
},
send_y: expected_y,
}],
}));
state.begin_spacing(doc.project(), begin).unwrap();
state.move_spacing(end);
let preview = state.spacing.as_ref().unwrap().preview.as_ref().unwrap();
assert_eq!(preview.project, expected);
let line = preview
.geometry
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert_eq!(line.points[0].y.to_bits(), expected_y.to_bits());
let Finish::Commit {
command,
selected,
scale,
pan,
} = state.finish_spacing(end).unwrap()
else {
panic!("fractional motion must remain a commit")
};
assert_eq!(selected, message);
assert_eq!(scale, 0.5);
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
let committed = oa_core::execute_batch(doc.project(), &[*command]).unwrap();
assert_eq!(committed, expected);
assert!(state.spacing_token().is_none());
assert_eq!(doc.project(), &original.decoded.project);
assert_eq!(doc.compiled().bundle, original.bundle);
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
assert!(camera == state.camera_boundary());
}
@@ -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<Id>, Vec<Id>) {
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::<BTreeSet<_>>(),
messages.iter().copied().collect::<BTreeSet<_>>()
);
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<u64> = 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::<BTreeSet<_>>().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}");
}
@@ -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<ScenarioLayout>) -> (Document, Id, Id) {
let mut project = language::parse(FIXTURE).unwrap();
let owner = id(&project, "conversation");
let mut add = |alias: &str, layout: Option<ScenarioLayout>| {
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::<Vec<_>>()
};
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::<Vec<_>>()
};
let boxes = |scene: &crate::behavior::Scene| {
scene
.boxes
.iter()
.map(|item| {
(
item.id,
item.rect,
item.title.clone(),
item.detail.clone(),
item.marker,
)
})
.collect::<Vec<_>>()
};
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; }; }; }
}
"#;
@@ -1,5 +1,6 @@
//! Navigation is derived from the same opaque-reference scene that is painted.
use super::*;
use crate::behavior::scenario_layout::{Representation, presentation};
use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one
@@ -22,7 +23,14 @@ fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
start < retained
}
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
fn usages(
p: &Project,
owner: Id,
subject: Id,
surface: Surface,
bounds: Rectangle,
scene: &Scene,
) -> Vec<Usage> {
let mut result = Vec::new();
let mut add = |id, bounds, role: &str| {
// Activation bars share the occurrence identity with a labelled message;
@@ -33,7 +41,7 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
{
result.push(Usage {
subject,
surface: Surface::Scenario(owner),
surface,
target: Target::ScenarioObject(id),
role: role.into(),
bounds,
@@ -137,15 +145,15 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
}
}
}
if subject == owner {
if subject == owner || subject == surface.id() {
result.insert(
0,
Usage {
subject,
surface: Surface::Scenario(owner),
surface,
target: Target::Whole,
role: "scenario".into(),
bounds: Rectangle::new(Point::ORIGIN, scene.size),
bounds,
},
);
}
@@ -153,15 +161,29 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
}
impl State {
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
if !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
/// Membership comes from the complete presentation chosen for this exact view,
/// including automatic fallback geometry while retaining its Saved identity.
pub(crate) fn representation_usages(
&self,
p: &Project,
representation: Representation,
subject: Id,
) -> Vec<Usage> {
let shown = presentation::present(p, representation, subject);
let (Some(owner), Some(bounds), Some(scene)) =
(shown.owner(), shown.bounds(), shown.scene())
else {
return vec![];
}
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
};
let surface = match representation {
Representation::Derived { owner } => Surface::Scenario(owner),
Representation::Saved { diagram } => Surface::Saved(diagram),
};
usages(p, owner, subject, surface, bounds, scene)
}
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
self.representation_usages(p, Representation::Derived { owner }, subject)
}
pub(crate) fn reveal_usage(
@@ -171,12 +193,24 @@ impl State {
owner: Id,
target: &Target,
) -> bool {
if self.draft.is_some()
|| !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
self.reveal_representation_usage(
p,
document,
owner,
Representation::Derived { owner },
target,
)
}
pub(crate) fn reveal_representation_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
representation: Representation,
target: &Target,
) -> bool {
if self.draft.is_some() || presentation::checked_owner(p, representation) != Some(owner) {
return false;
}
let id = match target {
@@ -185,7 +219,7 @@ impl State {
_ => return false,
};
let Some(usage) = self
.diagram_usages(p, owner, id)
.representation_usages(p, representation, id)
.into_iter()
.find(|u| &u.target == target)
else {
@@ -193,11 +227,29 @@ impl State {
};
// Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
if !viewport.width.is_finite()
|| !viewport.height.is_finite()
|| viewport.width <= 0.
|| viewport.height <= 0.
{
return false;
}
let (zoom, pan) = if matches!(target, Target::Whole) {
let Some(camera) = super::camera::fit(usage.bounds, viewport) else {
return false;
};
(None, camera.pan)
} else {
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
if !zoom.is_finite() || !pan.x.is_finite() || !pan.y.is_finite() {
return false;
}
(Some(zoom), pan)
};
self.enter_representation(document, owner, representation);
self.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select));
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
self.presentation.zoom(Some(zoom));
self.presentation.zoom(zoom);
self.presentation.pan = pan;
true
}
@@ -13,13 +13,14 @@ pub enum Shelf {
#[derive(Clone, Default)]
pub(super) struct Presentation {
pub(super) identity: Option<(Id, Id)>,
pub(super) representation: Option<Representation>,
pub(super) visit: Id,
pub(super) viewport: Option<Size>,
pub(super) shelf: Option<Shelf>,
pub(super) pan: iced::Vector,
pub(super) zoom: Option<f32>,
pub(super) fit: bool,
}
const MIN_SCALE: f32 = 0.05;
impl Presentation {
pub(super) fn viewport(&mut self, size: Size) -> bool {
if !size.width.is_finite()
@@ -34,29 +35,26 @@ impl Presentation {
true
}
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
return false;
}
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
let changed = self.zoom != next || self.fit != scale.is_none();
self.zoom = next;
self.fit = scale.is_none();
changed
camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale)
}
fn fit_scale(&self, scene: Size) -> Option<f32> {
let viewport = self.viewport?;
// Fit uses the actual mounted viewport with a small visual gutter.
Some(
((viewport.width - 16.).max(1.) / scene.width.max(1.))
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
)
pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera {
camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds)
}
pub(super) fn scale(&self, scene: Size) -> f32 {
if self.fit {
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
} else {
self.zoom.unwrap_or(1.)
}
// Legacy tests call size-only scale; actual display always passes full bounds.
#[cfg(test)]
pub(super) fn scale(&self, size: Size) -> f32 {
self.camera(Rectangle::new(Point::ORIGIN, size)).scale
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ArrangementChoice {
representation: Representation,
label: String,
}
impl std::fmt::Display for ArrangementChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
@@ -68,43 +66,136 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
.draft
.as_ref()
.is_some_and(|draft| draft.owner == owner);
let context = app.scenario_context().expect("visible scenario context");
let Some(context) = app.scenario_context() else {
return column![
out,
text("Scenario view is unavailable."),
button("Open selected source").on_press(Message::SourceForSelection)
]
.spacing(8)
.into();
};
let tool = state.canvas_tool(owner);
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let mut scene = Scene::sequence(p, owner, app.selected);
let scale = presentation.scale(scene.size);
scene.scale = scale;
scene.camera_pan = presentation.pan;
scene.interaction = Some((context, tool, clean));
scene.pending_placement = state.pending_placement(owner);
// Resolve once per accepted view. An active spacing session owns its exact
// prepared geometry; borrowing it also retains the canvas input state.
let preview = state.spacing_paint(context, app.camera_epoch);
let shown = preview
.is_none()
.then(|| presentation::present(p, context.representation, app.selected));
let capabilities = shown.as_ref().map_or(
presentation::PresentationCapabilities {
geometry_navigation: true,
coordinate_input: true,
},
ScenarioPresentation::capabilities,
);
let coordinate_input = clean && capabilities.coordinate_input;
let notice = if let Some(paint) = &preview {
display_notice_parts(
p,
context.representation,
&presentation::PresentationStatus::Ready,
paint.original_diagnostics,
paint.original_latent,
)
} else {
display_notice(p, shown.as_ref().expect("normal presentation"))
};
let camera_state = preview
.as_ref()
.map(|paint| camera::Camera {
scale: paint.scene.scale,
pan: paint.scene.camera_pan,
})
.or_else(|| {
shown
.as_ref()
.and_then(ScenarioPresentation::bounds)
.map(|bounds| presentation.camera(bounds))
});
let scale = camera_state.map_or(1., |camera| camera.scale);
let scope = super::super::canvas_workspace::Scope {
document: context.document,
owner,
revision: context.revision,
epoch: app.camera_epoch,
scenario_view: Some((context.representation, context.visit)),
};
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
let keyboard = clean
&& capabilities.geometry_navigation
&& super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let canvas = container(
sensor(
super::super::canvas_workspace::Viewport::new(
canvas::Canvas::new(scene).width(Fill).height(Fill),
scope,
scale,
keyboard,
camera,
)
.pan_background(tool == CanvasTool::Select),
let raw_canvas: Option<Element<'a, Message>> = if let Some(paint) = &preview {
Some(
canvas::Canvas::new(paint.scene)
.width(Fill)
.height(Fill)
.into(),
)
.key((context.document, owner))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.id("scenario-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into();
} else if let Some(mut scene) = shown.and_then(ScenarioPresentation::into_scene) {
let camera_state = camera_state.expect("displayed Scene has complete bounds");
scene.scale = camera_state.scale;
scene.camera_pan = camera_state.pan;
scene.interaction = Some((context, tool, clean));
scene.spacing_stamp = (coordinate_input
&& !app.source_rename.open()
&& keyboard
&& matches!(context.representation, Representation::Saved { .. }))
.then_some(spacing::Stamp {
context,
camera_epoch: app.camera_epoch,
});
scene.coordinate_input = capabilities.coordinate_input;
scene.pending_placement = coordinate_input
.then(|| state.pending_placement(owner))
.flatten();
Some(canvas::Canvas::new(scene).width(Fill).height(Fill).into())
} else {
None
};
let canvas: Element<'a, Message> = if let Some(raw_canvas) = raw_canvas {
// Keep this Stack in both branches: replacing the Canvas with a wrapper
// on Begin would discard its pointer capture before Move/Finish.
let mut layers = iced::widget::stack![raw_canvas];
if let Some(paint) = &preview {
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
layers = layers.push(
container(
container(text(feedback).size(12))
.padding(6)
.width(Fill)
.style(style::card),
)
.width(Fill)
.align_bottom(Fill)
.padding(8),
);
}
container(
sensor(
super::super::canvas_workspace::Viewport::new(
layers, scope, scale, keyboard, camera,
)
.pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input),
)
.key((
context.document,
format!("{:?}", context.representation),
context.visit,
))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.id("scenario-viewport")
.width(Fill)
.height(Fill)
.style(style::card)
.into()
} else {
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
};
let mut navigation = row![out.width(Fill)].spacing(6);
if state.can_back(p, context) {
navigation =
@@ -112,20 +203,72 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
));
}
let header = column![
navigation,
super::super::canvas_workspace::controls(
let mut header = column![navigation, notice].spacing(4);
let saved_views = p
.diagrams
.values()
.filter(|diagram| {
presentation::checked_owner(
p,
Representation::Saved {
diagram: diagram.id,
},
) == Some(owner)
})
.map(|diagram| ArrangementChoice {
representation: Representation::Saved {
diagram: diagram.id,
},
label: short(&diagram.name, 64),
})
.collect::<Vec<_>>();
if !saved_views.is_empty() {
let mut choices = vec![ArrangementChoice {
representation: Representation::Derived { owner },
label: "Automatic arrangement".into(),
}];
choices.extend(saved_views);
let selected = choices
.iter()
.find(|choice| choice.representation == context.representation)
.cloned();
header = header.push(
container(
pick_list(choices, selected, move |choice: ArrangementChoice| {
Message::ScenarioCanvas(CanvasEvent::Representation(
context,
choice.representation,
))
})
.placeholder("Choose arrangement")
.text_size(12)
.padding(5)
.width(Fill),
)
.width(Fill)
.id("scenario-arrangement-picker"),
);
}
if capabilities.geometry_navigation {
header = header.push(super::super::canvas_workspace::controls(
scope,
keyboard,
scale,
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
]
.spacing(4);
camera,
));
}
let entries = [
("S", "Select / reorder occurrences", CanvasTool::Select),
(
"S",
if matches!(context.representation, Representation::Saved { .. }) {
"Select / move messages and lifelines"
} else {
"Select / reorder occurrences"
},
CanvasTool::Select,
),
(
"L",
"Lifeline: click to choose represented work or participant",
@@ -192,16 +335,21 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
glyph,
label,
choice == tool,
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
(clean && (choice == CanvasTool::Select || capabilities.coordinate_input))
.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
)
}),
);
let mut details = column![
text(format!("Selected: {}", p.named(app.selected))).size(12),
button(text("Properties").size(12))
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
container(button(text("Properties").size(12)).on_press_maybe(
(clean && properties_target(p, context, app.selected)).then_some(
Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)),
),
))
.id("scenario-properties")
]
.spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
@@ -212,6 +360,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
))),
));
}
details = details.push(text("Scenario order").size(13));
if matches!(context.representation, Representation::Saved { .. }) {
details = details.push(text("↑ and ↓ change the shared Scenario order. Saved message positions are retained; automatically placed items may move.").size(11));
}
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
@@ -541,6 +693,153 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
}
fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> {
display_notice_parts(
p,
shown.requested(),
shown.status(),
shown.diagnostics(),
shown.latent_messages(),
)
}
fn display_notice_parts<'a>(
p: &Project,
requested: Representation,
status: &presentation::PresentationStatus,
diagnostics: &[super::super::scenario_layout::Diagnostic],
latent_messages: &[Id],
) -> Element<'a, Message> {
use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus};
let title = match requested {
Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else(
|| "Unavailable saved Scenario view".into(),
|view| format!("Saved view · {}", view.name),
),
Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)),
};
let mut notice = column![text(title).size(14)].spacing(4);
match status {
PresentationStatus::Ready => {
for diagnostic in diagnostics.iter().take(3) {
let explanation = match diagnostic {
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
p.named(*before),
p.named(*after)
),
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
p.named(*frame),
p.named(*following)
),
};
notice = notice.push(text(explanation).size(12));
}
if diagnostics.len() > 3 {
notice = notice.push(
text(format!(
"{} additional spacing notices are retained.",
diagnostics.len() - 3
))
.size(12),
);
}
}
PresentationStatus::Fallback { requested_issues } => {
notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12));
if let Some(issue) = requested_issues.first() {
notice = notice.push(text(geometry_problem(p, issue)).size(12));
}
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
}
PresentationStatus::Unavailable { .. } => {
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
}
}
if !latent_messages.is_empty() {
notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", latent_messages.len())).size(12));
}
notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection));
container(notice)
.id("scenario-display-notice")
.padding(6)
.width(Fill)
.into()
}
fn spacing_feedback(
p: &Project,
refusal: Option<&spacing::Refusal>,
diagnostics: &[super::super::scenario_layout::Diagnostic],
) -> String {
if let Some(refusal) = refusal {
let reason = match refusal {
spacing::Refusal::Geometry(issues) => issues
.first()
.map(|issue| geometry_problem(p, issue))
.unwrap_or_else(|| "This position cannot be displayed.".into()),
spacing::Refusal::Command(error) => error.clone(),
_ => "This position is unavailable in the current view.".into(),
};
return format!(
"Position unavailable · {reason} Move back to a supported position, or release to cancel."
);
}
let explanation = match diagnostics.first() {
Some(super::super::scenario_layout::Diagnostic::SourceOrderCrossing {
before,
after,
..
}) => format!(
" '{}' is below '{}'; Scenario order stays unchanged.",
p.named(*before),
p.named(*after)
),
Some(super::super::scenario_layout::Diagnostic::FrameExtendsPastFollowingAnchor {
frame,
following,
}) => format!(" '{}' overlaps '{}'.", p.named(*frame), p.named(*following)),
None => " Scenario order stays unchanged.".into(),
};
format!("Spacing preview · Release to finish; Escape cancels.{explanation}")
}
fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String {
use super::super::scenario_layout::GeometryIssue;
match issue {
GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!(
"The position of '{}' conflicts with the execution of '{}'.",
p.named(*event),
p.named(*call)
),
GeometryIssue::CoincidentDistinctEndpoints(message) => format!(
"The distinct endpoints of '{}' have the same horizontal position.",
p.named(*message)
),
GeometryIssue::TypedActivationOutsideLifetime { call, .. }
| GeometryIssue::TypedActivationOutsideScope { call, .. } => format!(
"The position of '{}' falls outside its supported execution interval.",
p.named(*call)
),
GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!(
"The saved spacing would move the fixed header of '{}'.",
p.named(*frame)
),
GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!(
"The spacing of '{}' extends outside its enclosing frame.",
p.named(*operand)
),
GeometryIssue::LifecycleGeometryNotSupported { .. }
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
| GeometryIssue::LifetimeInversionNotSupported { .. } => {
"The saved spacing cannot preserve the participant lifetime geometry.".into()
}
_ => "These saved positions are outside the geometry currently supported by the canvas."
.into(),
}
}
fn sequence_action<'a>(
title: &str,
message: Message,
@@ -597,6 +896,12 @@ fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
#[cfg(all(test, debug_assertions))]
mod tests;
#[cfg(all(test, debug_assertions))]
mod spacing_tests;
#[cfg(all(test, debug_assertions))]
mod details_tests;
fn sequence_rows<'a>(
p: &'a Project,
context: Context,
@@ -0,0 +1,437 @@
//! Mounted Scenario Details → inspector regression and retained-intent guards.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario_layout::resolve;
use iced::advanced::widget;
use iced::{Vector, keyboard};
use std::sync::Arc;
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn setup() -> (App, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let owner = id(&app, "incident_response");
let diagram = id(&app, "incident_readable");
let message = id(&app, "initial_report");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: "A".into(),
}));
send(&mut app, |c| CanvasEvent::Select(c, message));
app.window_size = Size::new(1440., 1000.);
(app, diagram, message)
}
fn open_details(app: &mut App) {
if app.behavior.scenario.presentation.shelf.is_none() {
send(app, |c| CanvasEvent::Shelf(c, Shelf::Selection));
}
}
fn stored_camera(app: &App) -> (Vector, Option<f32>, bool) {
let p = &app.behavior.scenario.presentation;
(p.pan, p.zoom, p.fit)
}
#[tokio::test]
async fn saved_details_opens_real_editable_inspector_and_returns_to_same_view() {
let (mut app, diagram, message) = setup();
let _ = app.update(Message::InspectorTab(
crate::navigation::InspectorTab::Connections,
));
let mut mounted = Mounted::new(&mut app).await;
open_details(&mut app);
mounted.settle(&mut app).await;
let before = mounted.geometry(&app);
assert!(before.scrolls.contains_key(&widget::Id::new("inspector")));
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
let button = before.bounds("scenario-properties").center() - offset;
assert!(
details.contains(button),
"Properties must be actually visible"
);
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let original = app.document.snapshot();
let bundle = app.document.bundle().clone();
let messages = mounted.click(&mut app, button).await;
assert!(messages.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
if *c == context && *selected == message)));
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.presentation.shelf.is_none());
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, message);
assert_eq!(app.active_diagram, Some(diagram));
assert_eq!(app.page, crate::app::Page::Behavior);
let current = app.scenario_context().unwrap();
assert_eq!(current.representation, context.representation);
assert_eq!(current.visit, context.visit);
assert_eq!(
stored_camera(&app),
camera,
"restoring panels retains camera intent"
);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
let shown = mounted.geometry(&app);
assert!(
!shown
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
assert!(inspector.width > 250. && inspector.height > 500.);
// Production Properties places Name before the other scalar text inputs.
// Account for the retained inspector scroll, rather than clicking content coordinates.
let input = shown
.text_inputs
.iter()
.map(|bounds| bounds.center() - offset)
.filter(|point| inspector.contains(*point))
.min_by(|a, b| a.y.total_cmp(&b.y))
.expect("visible editable property");
mounted.click(&mut app, input).await;
let typed = mounted
.batch(
&mut app,
&[iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character("x".into()),
modified_key: keyboard::Key::Character("x".into()),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some("x".into()),
repeat: false,
})],
mouse::Cursor::Available(input),
)
.await;
assert!(
typed
.iter()
.any(|message| matches!(message, Message::Field(path, _) if path == "/name")),
"real Name editor must publish its edit: {typed:?}"
);
assert!(app.draft_dirty());
assert!(
Arc::ptr_eq(&original, &app.document.snapshot()),
"typing is only a draft"
);
let _ = app.update(Message::RevertProperties);
assert!(!app.draft_dirty());
mounted.settle(&mut app).await;
let returned = mounted.geometry(&app);
assert!(returned.scrolls.contains_key(&widget::Id::new("inspector")));
let viewport = returned.bounds("scenario-viewport");
assert_eq!(app.selected, message);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(stored_camera(&app), camera);
let geometry = resolve(app.project(), context.representation, message).unwrap();
let line = geometry
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
let effective = app.behavior.scenario.presentation.camera(geometry.bounds);
let point = viewport.position()
+ effective.pan
+ Vector::new(line.points[0].x, line.points[0].y) * effective.scale;
assert!(
viewport.contains(point),
"the same selected message remains in fitted view"
);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_refuses_pending_property_source_and_behavior_drafts_without_apply() {
for kind in 0..3 {
let (mut app, _, message) = setup();
open_details(&mut app);
match kind {
0 => {
let _ = app.update(Message::Field(
"/name".into(),
"Retained property draft".into(),
));
assert!(app.draft_dirty());
}
1 => {
let _ = app.update(Message::SourceEdit(
iced::widget::text_editor::Action::Edit(
iced::widget::text_editor::Edit::Insert('x'),
),
));
assert!(app.source_dirty());
}
_ => {
let owner = app.scenario_context().unwrap().scenario;
let _ = app.update(Message::Behavior(super::super::super::Event::Scenario(
Event::Start(owner, Tool::Lifeline),
)));
assert!(app.behavior.has_pending());
}
}
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let property = app.draft.as_ref().unwrap().materialize();
let source = app.source_editor.text();
let behavior = app.behavior.recovery_snapshot();
let camera = app.behavior.scenario.camera_boundary();
let context = app.scenario_context().unwrap();
let epochs = (app.canvas_epoch, app.camera_epoch);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
context, message,
)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert_eq!(app.draft.as_ref().unwrap().materialize(), property);
assert_eq!(app.source_editor.text(), source);
assert_eq!(app.behavior.recovery_snapshot(), behavior);
assert_eq!(app.selected, message);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[tokio::test]
async fn details_opens_current_saved_view_properties_after_following_diagram_picker() {
let (mut app, a, _) = setup();
let b = id(&app, "incident_wide");
// Selecting the current view record makes the real picker follow that
// record selection to B. Do not seed selected=B or its property draft.
let _ = app.update(Message::Select(a));
assert_eq!(app.selected, a);
let _ = app.update(Message::Diagram(crate::app::Choice {
id: b,
label: "B".into(),
}));
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
let _ = app.update(Message::InspectorTab(
crate::navigation::InspectorTab::Connections,
));
let mut mounted = Mounted::new(&mut app).await;
open_details(&mut app);
mounted.settle(&mut app).await;
let context = app.scenario_context().unwrap();
let camera = stored_camera(&app);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let before = mounted.geometry(&app);
assert!(before.scrolls.contains_key(&widget::Id::new("inspector")));
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
let point = before.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
if *c == context && *selected == b)));
mounted.settle(&mut app).await;
let shown = mounted.geometry(&app);
assert!(
!shown
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
assert!(
shown
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current Saved diagram has a visible editable inspector"
);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.page, crate::app::Page::Behavior);
assert_eq!(
app.scenario_context().unwrap().representation,
context.representation
);
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
assert_eq!(stored_camera(&app), camera);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_refuses_another_saved_view_even_with_the_same_scenario_owner() {
let (mut app, a, _) = setup();
let b = id(&app, "incident_wide");
let _ = app.update(Message::Diagram(crate::app::Choice {
id: b,
label: "B".into(),
}));
let _ = app.update(Message::Select(a));
assert_eq!(app.selected, a);
open_details(&mut app);
let context = app.scenario_context().unwrap();
assert_eq!(context.representation, Representation::Saved { diagram: b });
assert_eq!(
presentation::checked_owner(app.project(), Representation::Saved { diagram: a }),
Some(context.scenario)
);
assert!(!properties_target(app.project(), context, a));
let camera = app.behavior.scenario.camera_boundary();
let epochs = (app.canvas_epoch, app.camera_epoch);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(context, a)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!(app.selected, a);
assert_eq!(app.draft.as_ref().unwrap().original, a);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn details_properties_rejects_retired_view_selection_and_epoch_without_changing_panels() {
for kind in 0..3 {
let (mut app, diagram, message) = setup();
open_details(&mut app);
let captured = app.scenario_context().unwrap();
match kind {
0 => {
let other = id(&app, "incident_wide");
for view in [other, diagram] {
let _ = app.update(Message::Diagram(crate::app::Choice {
id: view,
label: "view".into(),
}));
}
open_details(&mut app);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
}
1 => {
let other = id(&app, "updated_report");
send(&mut app, |c| CanvasEvent::Select(c, other));
}
_ => {
send(&mut app, |c| CanvasEvent::Zoom(c, Some(0.5)));
}
}
let camera = app.behavior.scenario.camera_boundary();
let context = app.scenario_context();
let selected = app.selected;
let tab = app.inspector_tab;
let epochs = (app.canvas_epoch, app.camera_epoch);
let snapshot = app.document.snapshot();
let bundle = app.document.bundle().clone();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
captured, message,
)));
assert!(app.behavior.scenario.camera_boundary() == camera);
assert_eq!(app.scenario_context(), context);
assert_eq!(app.selected, selected);
assert_eq!(app.inspector_tab, tab);
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &bundle);
}
}
#[test]
fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish() {
use super::super::spacing::{Event as Spacing, Target, controller::tests::packet};
let (mut app, _, message) = setup();
send(&mut app, |c| {
CanvasEvent::Viewport(c, Size::new(900., 600.))
});
open_details(&mut app);
let begin = packet(
app.project(),
&app.behavior.scenario,
app.scenario_context().unwrap(),
app.camera_epoch,
Target::Message(message),
);
let Spacing::Begin { press_screen, .. } = begin else {
panic!()
};
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin)));
assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token()));
let snapshot = app.document.snapshot();
let end = press_screen + Vector::new(0., 12.);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Move {
stamp: begin.stamp(),
token: begin.token(),
screen: end,
},
)));
let paint = app
.behavior
.scenario
.spacing_paint(begin.stamp().context, begin.stamp().camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
let line = paint
.scene
.lines
.iter()
.find(|line| line.id == message)
.unwrap();
assert!(
line.points[0].y > 160.,
"a real proposed scene moved the first message"
);
assert!(
Arc::ptr_eq(&snapshot, &app.document.snapshot()),
"preview is not authored"
);
let camera = stored_camera(&app);
send(&mut app, |c| CanvasEvent::Properties(c, message));
assert!(app.behavior.scenario.spacing_token().is_none());
assert_eq!(stored_camera(&app), camera);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
Spacing::Finish {
stamp: begin.stamp(),
token: begin.token(),
screen: end,
},
)));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
@@ -0,0 +1,438 @@
//! Real mounted widgets dispatch the gesture; expectations name authored fields
//! and actual displayed anchors independently of controller command results.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use crate::behavior::scenario_layout::{ResolvedScenarioPlan, resolve};
use crate::document::Document;
use iced::Vector;
use std::sync::Arc;
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
async fn setup(fit: bool) -> (App, Mounted, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let owner = id(&app, "incident_response");
let diagram = id(&app, "incident_readable");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Diagram(crate::app::Choice {
id: diagram,
label: "A - Incident review readable".into(),
}));
app.window_size = Size::new(1440., 900.);
let mut mounted = Mounted::new(&mut app).await;
if !fit {
let context = app.scenario_context().unwrap();
let task = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(0.5),
)));
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
}
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram }
);
assert!(!app.document.can_undo() && !app.document.can_redo());
(app, mounted, diagram)
}
fn resolved(app: &App) -> ResolvedScenarioPlan {
resolve(
app.project(),
app.scenario_context().unwrap().representation,
app.selected,
)
.unwrap()
}
fn stored_camera(app: &App) -> (Vector, Option<f32>, 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<Message> {
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<Message> {
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<Message> {
mounted
.batch(
app,
&[mouse_event(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(point),
)
.await
}
fn set_expected_message(project: &mut oa_core::Project, diagram: Id, occurrence: Id, y: f32) {
let Some(oa_core::DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&diagram).unwrap().layout
else {
panic!()
};
layout
.messages
.iter_mut()
.find(|m| m.address.occurrence == occurrence)
.unwrap()
.send_y = y;
}
fn set_expected_lane(project: &mut oa_core::Project, diagram: Id, lifeline: Id, x: f32) {
let Some(oa_core::DiagramLayout::Scenario(layout)) =
&mut project.diagrams.get_mut(&diagram).unwrap().layout
else {
panic!()
};
layout
.lanes
.iter_mut()
.find(|lane| lane.lifeline == lifeline)
.unwrap()
.x = x;
}
fn assert_reopens_exactly(app: &App) {
let reopened = Document::from_bundle(
app.document.bundle().clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), app.document.bundle());
}
#[tokio::test]
async fn saved_message_same_batch_pointer_events_commit_once_and_one_undo_redo_restores_exact_source()
{
let (mut app, mut mounted, diagram) = setup(false).await;
let target = id(&app, "updated_report");
let before = app.document.snapshot();
let revision = app.document.revision();
let projected = screen(&mut mounted, &app, Point::new(400., 252.));
// This fixture asks for exactly +20 world units. A fractional start near a
// screen-coordinate exponent boundary need not retain an exact +10 delta.
let start = Point::new(projected.x.round(), projected.y.round());
let mut geometry = resolved(&app);
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
assert_eq!(camera.scale, 0.5);
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let world = Point::new(
(start.x - bounds.x - camera.pan.x) / camera.scale,
(start.y - bounds.y - camera.pan.y) / camera.scale,
);
geometry.scene.scale = camera.scale;
geometry.scene.camera_pan = camera.pan;
let hit = geometry.scene.hit_item(world).unwrap();
assert_eq!(hit.id, target);
assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody);
let end = start + Vector::new(0., 10.); // .5 display scale: +20 authored y.
assert_eq!(end.y - start.y, 10.);
assert!(bounds.contains(start) && bounds.contains(end));
let messages = mounted
.batch(
&mut app,
&[
mouse_event(mouse::Event::CursorMoved { position: start }),
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
mouse_event(mouse::Event::CursorMoved { position: end }),
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(end),
)
.await;
assert_eq!(
messages
.iter()
.filter(|m| matches!(
m,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. }))
))
.count(),
1
);
assert_eq!(
messages
.iter()
.filter(|m| matches!(
m,
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
))
.count(),
1
);
let mut expected = before.decoded.project.clone();
set_expected_message(&mut expected, diagram, target, 272.);
assert_eq!(
app.project(),
&expected,
"Only Saved A's addressed y changes; steps, other occurrences and B/C are exact"
);
assert_eq!(app.document.revision(), revision + 1);
assert_eq!(app.selected, target);
assert!(app.behavior.scenario.spacing_token().is_none());
assert_reopens_exactly(&app);
let after = app.document.snapshot();
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &after.decoded.project);
assert_eq!(app.document.bundle(), &after.bundle);
}
#[tokio::test]
async fn saved_header_preview_survives_frames_and_freezes_fit_while_actual_bounds_expand() {
let (mut app, mut mounted, diagram) = setup(true).await;
let assessor = id(&app, "assessor");
let before = app.document.snapshot();
let original = resolved(&app);
let original_camera = app.behavior.scenario.presentation.camera(original.bounds);
let stored = stored_camera(&app);
assert!(stored.2, "test must exercise original Fit");
let start = screen(&mut mounted, &app, Point::new(640., 45.));
press(&mut mounted, &mut app, start).await;
let token = app
.behavior
.scenario
.spacing_token()
.expect("actual header press begins");
for x in [670., 700.] {
let end = start + Vector::new((x - 640.) * original_camera.scale, 0.);
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await; // Actual borrowed Scene/Stack tree is rebuilt.
assert_eq!(app.behavior.scenario.spacing_token(), Some(token));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), stored);
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
assert!((paint.scene.render_lanes[&assessor] - x).abs() < 0.001);
assert!(paint.bounds.width > original.bounds.width);
assert_eq!(paint.scene.scale, original_camera.scale);
assert_eq!(paint.scene.camera_pan, original_camera.pan);
let header = paint.scene.boxes.iter().find(|b| b.id == assessor).unwrap();
let displayed = Vector::new(header.rect.center().x, header.rect.center().y)
* paint.scene.scale
+ paint.scene.camera_pan;
let baseline = Vector::new(640., 50.) * original_camera.scale + original_camera.pan;
assert!((displayed.x - baseline.x - (x - 640.) * original_camera.scale).abs() < 0.001);
assert_eq!(displayed.y, baseline.y);
}
let end = start + Vector::new(60. * original_camera.scale, 0.);
release(&mut mounted, &mut app, end).await;
let final_geometry = resolved(&app);
let actual_x = final_geometry.scene.render_lanes[&assessor];
assert!((actual_x - 700.).abs() < 0.001);
let mut expected = before.decoded.project.clone();
set_expected_lane(&mut expected, diagram, assessor, actual_x);
assert_eq!(app.project(), &expected);
let final_camera = app
.behavior
.scenario
.presentation
.camera(final_geometry.bounds);
assert_eq!(
final_camera, original_camera,
"commit must not refit underneath pointer"
);
assert!(!app.behavior.scenario.presentation.fit);
assert_eq!(app.selected, assessor);
assert_reopens_exactly(&app);
}
#[tokio::test]
async fn saved_body_and_header_clicks_select_without_materializing_spacing() {
let (mut app, mut mounted, _) = setup(false).await;
let before = app.document.snapshot();
let original_camera = stored_camera(&app);
for (alias, world) in [
("updated_report", Point::new(400., 252.)),
("assessor", Point::new(640., 45.)),
] {
let target = id(&app, alias);
let point = screen(&mut mounted, &app, world);
mounted.click(&mut app, point).await;
assert_eq!(app.selected, target);
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), original_camera);
}
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_valid_preview_then_refused_self_call_never_commits_last_valid_candidate() {
let (mut app, mut mounted, _) = setup(false).await;
let target = id(&app, "assess_call");
let before = app.document.snapshot();
let camera = stored_camera(&app);
let start = screen(&mut mounted, &app, Point::new(680., 344.));
press(&mut mounted, &mut app, start).await;
let valid = start + Vector::new(0., -10.); // send324, receive348, reply436 unchanged.
move_to(&mut mounted, &mut app, valid).await;
mounted.settle(&mut app).await;
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_none());
let line = paint.scene.lines.iter().find(|l| l.id == target).unwrap();
assert_eq!(line.points[0].y, 324.);
assert_eq!(line.points.last().unwrap().y, 348.);
let refused = start + Vector::new(0., 53.); // send450, receive474 would follow reply436.
move_to(&mut mounted, &mut app, refused).await;
mounted.settle(&mut app).await;
let paint = app
.behavior
.scenario
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
.unwrap();
assert!(paint.refusal.is_some());
assert_eq!(
paint
.scene
.lines
.iter()
.find(|l| l.id == target)
.unwrap()
.points[0]
.y,
344.
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
release(&mut mounted, &mut app, refused).await;
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[tokio::test]
async fn saved_escape_and_outside_pointer_cancel_actual_held_preview() {
for escape in [true, false] {
let (mut app, mut mounted, _) = setup(false).await;
let before = app.document.snapshot();
let camera = stored_camera(&app);
let start = screen(&mut mounted, &app, Point::new(400., 252.));
let end = start + Vector::new(0., 10.);
press(&mut mounted, &mut app, start).await;
move_to(&mut mounted, &mut app, end).await;
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.spacing_token().is_some());
let release_at = if escape {
mounted
.batch(
&mut app,
&[key(iced::keyboard::key::Named::Escape)],
mouse::Cursor::Available(end),
)
.await;
end
} else {
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let outside = Point::new(bounds.x - 5., bounds.y + 100.);
move_to(&mut mounted, &mut app, outside).await;
outside
};
assert!(app.behavior.scenario.spacing_token().is_none());
release(&mut mounted, &mut app, release_at).await;
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(stored_camera(&app), camera);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[tokio::test]
async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_camera() {
let (mut app, mut mounted, _) = setup(true).await;
let owner = id(&app, "incident_response");
app.document
.execute(oa_core::Command::Rename(
owner,
"Previously edited owner".into(),
))
.unwrap();
let redo = app.document.snapshot();
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
mounted.settle(&mut app).await;
assert!(app.document.can_redo());
let before = app.document.snapshot();
let camera = stored_camera(&app);
let selected = app.selected;
let revision = app.document.revision();
let start = screen(&mut mounted, &app, Point::new(400., 252.));
press(&mut mounted, &mut app, start).await;
move_to(&mut mounted, &mut app, start + Vector::new(0., 12.)).await;
mounted.settle(&mut app).await;
assert!(app.behavior.scenario.spacing_token().is_some());
move_to(&mut mounted, &mut app, start).await;
mounted.settle(&mut app).await;
release(&mut mounted, &mut app, start).await;
assert!(app.behavior.scenario.spacing_token().is_none());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), revision);
assert_eq!(stored_camera(&app), camera);
assert_eq!(app.selected, selected);
assert!(app.document.can_redo());
let task = app.update(Message::Redo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &redo.decoded.project);
assert_eq!(app.document.bundle(), &redo.bundle);
}
@@ -57,8 +57,28 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
mounted.pan(&mut app, start, start + delta).await;
let diff = app.behavior.scenario.presentation.pan - next_pan;
assert!(diff.x.hypot(diff.y) < 0.01);
let tool = mounted.geometry(&app).bounds("behavior-tool:A").center();
mounted.click(&mut app, tool).await;
// Iced operations report palette children in content coordinates.
// The preceding Action focus may have scrolled this retained palette.
let geometry = mounted.geometry(&app);
let scope = canvas_workspace::Scope::current(&app).unwrap();
let (palette, offset) = geometry.scrolls[&scope.control_id("palette-scroll")];
let tool = geometry.bounds("behavior-tool:A").center() - offset;
assert!(
palette.contains(tool),
"visible Action at {tool:?}: {palette:?}"
);
let chosen = mounted.click(&mut app, tool).await;
assert!(
chosen.iter().any(|message| matches!(
canvas_workspace::tests::intent_action(message),
Message::ScenarioCanvas(CanvasEvent::Choose(_, CanvasTool::Add(Tool::Action)))
)),
"Action palette click at scale {scale}, offset {offset:?}: {chosen:?}"
);
assert_eq!(
app.behavior.scenario.canvas_tool(f.owner),
CanvasTool::Add(Tool::Action)
);
let messages = mounted.click(&mut app, bounds.position() + desired).await;
assert!(
messages.iter().any(
@@ -66,7 +86,7 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
tool: Tool::Action, slot, lifeline: Some(line), ..
}) if *slot == expected && *line == f.sender)
),
"scale {scale}: {messages:?}"
"scale {scale}, expected slot {expected:?}: {messages:?}"
);
let session = app.behavior.scenario.form_session().unwrap();
let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation;
@@ -1348,7 +1368,18 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
let point = bounds.position() + iced::Vector::new(95., 90.);
let lifetime = app.camera_epoch;
let pan = app.behavior.scenario.presentation.pan;
// Fit stores no pan offset. A real drag starts from the displayed fitted
// camera, then materializes it before applying its ordered screen deltas.
let shown = app
.behavior
.scenario
.display(app.project(), f.owner, app.selected)
.unwrap();
let before_camera = app
.behavior
.scenario
.presentation
.camera(shown.bounds().unwrap());
let first = point + iced::Vector::new(13., 9.);
let last = first + iced::Vector::new(17., -3.);
let emitted = mounted
@@ -1363,7 +1394,21 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
mouse::Cursor::Available(last),
)
.await;
assert_eq!(app.behavior.scenario.presentation.pan, pan + (last - point));
let expected_pan = before_camera.pan + (last - point);
let actual_pan = app.behavior.scenario.presentation.pan;
let difference = actual_pan - expected_pan;
assert!(
difference.x.hypot(difference.y) < 0.001,
"{boundary}: displayed pan {actual_pan:?} must follow {expected_pan:?}"
);
assert_eq!(
app.behavior.scenario.presentation.zoom,
Some(before_camera.scale)
);
assert!(
!app.behavior.scenario.presentation.fit,
"drag materializes the visible Fit camera"
);
assert_eq!(
app.camera_epoch, lifetime,
"own ordered deltas retain their lifetime"
@@ -1768,14 +1813,19 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp
assert!(!presentation.viewport(Size::new(484., 204.)));
let scale = presentation.scale(graph);
assert!(graph.width * scale <= 468. && graph.height * scale <= 188.);
assert!(scale > MIN_SCALE);
assert!(scale > camera::MIN_SCALE);
for bad in [f32::NAN, f32::INFINITY, 0., -1.] {
assert!(!presentation.zoom(Some(bad)));
assert!(!presentation.viewport(Size::new(bad, 100.)));
assert!(!presentation.viewport(Size::new(100., bad)));
assert_eq!(presentation.scale(graph), scale);
}
assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE);
let tall = Rectangle::with_size(Size::new(900., 10000.));
let camera = presentation.camera(tall);
assert!(camera.scale < 0.05 && camera.scale > 0.);
assert!((camera.scale - 188. / 10000.).abs() < 1e-8);
assert!((tall.height * camera.scale + camera.pan.y - 196.).abs() < 1e-4);
assert!((camera.pan.y - 8.).abs() < 1e-4);
assert!(presentation.zoom(Some(1.25)));
presentation.viewport(Size::new(200., 100.));
assert_eq!(
@@ -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);
}
}
@@ -0,0 +1,430 @@
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
//!
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
//! This adapter uses that same allocator/font system/attributes, not a character
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
//! Experimental parity requires settled fonts and a fresh renderer/cache after
//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely
//! remeasuring here cannot repair its cached glyph buffers after dynamic loading.
//! A RenderSession is caller attestation of that lifecycle, not its enforcement.
//! No fonts may be loaded between measurement, guarded access and actual draw.
//!
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
//! supplies a usable text width in world units and retains one cache per live
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
//! consume the returned Label. Position/color do not invalidate measurement.
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
//! It measures layout advances, not raster ink; overhang/clip/activation-border
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
//! renderer is deliberately untouched for a later baseline comparison.
use iced::advanced::{
graphics::text as graphics_text,
text::{Alignment, LineHeight, Shaping},
};
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
use std::{collections::VecDeque, ops::Range, sync::Arc};
use unicode_segmentation::UnicodeSegmentation;
const MAX_SOURCE_BYTES: usize = 64 * 1024;
const MAX_GRAPHEMES: usize = 4096;
const MAX_ENTRIES: usize = 2;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Style {
pub font: Font,
pub size: Pixels,
pub line_height: LineHeight,
pub shaping: Shaping,
}
impl Default for Style {
fn default() -> Self {
let text = Text::default();
Self {
font: text.font,
size: Pixels(12.),
line_height: text.line_height,
shaping: text.shaping,
}
}
}
impl Style {
fn scaled(self, zoom: f32) -> (f32, f32) {
let size = Pixels(self.size.0 * zoom);
let line_height = match self.line_height {
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
LineHeight::Relative(value) => LineHeight::Relative(value),
};
(size.0, line_height.to_absolute(size).0)
}
}
/// Explicit experimental contract: the caller finishes loading fonts and starts
/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed.
/// This module cannot inspect or reset the actual renderer's text cache.
#[derive(Clone, Debug)]
pub(super) struct RenderSession {
version: graphics_text::Version,
identity: Arc<()>,
}
impl RenderSession {
pub fn after_fonts_settled_and_renderer_reset() -> Result<Self, Error> {
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
Ok(Self {
version: fonts.version(),
identity: Arc::new(()),
})
}
}
#[derive(Clone, Debug)]
pub(super) struct Label {
source: String,
displayed: String,
/// Original-label bytes represented before any added ellipsis. Includes
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
retained_source: Range<usize>,
/// World-space layout advances, at origin; not glyph-ink containment.
bounds: Rectangle,
style: Style,
measured_session: RenderSession,
measured_zoom: f32,
}
impl Label {
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
return Err(Error::StaleRenderSession);
}
if !zoom.is_finite() || zoom != self.measured_zoom {
return Err(Error::StaleZoom);
}
let fonts = graphics_text::font_system()
.read()
.map_err(|_| Error::FontSystemUnavailable)?;
if fonts.version() != self.measured_session.version || fonts.version() != session.version {
return Err(Error::FontSetChanged);
}
Ok(())
}
pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> {
self.check(session, zoom)?;
Ok(&self.displayed)
}
pub fn retained_source(
&self,
session: &RenderSession,
zoom: f32,
) -> Result<Range<usize>, Error> {
self.check(session, zoom)?;
Ok(self.retained_source.clone())
}
pub fn retains_any(
&self,
session: &RenderSession,
zoom: f32,
field: Range<usize>,
) -> Result<bool, Error> {
self.check(session, zoom)?;
if field.start > field.end || self.source.get(field.clone()).is_none() {
return Ok(false);
}
let end = field.end.min(self.retained_source.end);
Ok(field.start < end
&& self
.source
.get(field.start..end)
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace())))
}
/// Validate finite world position and rectangle endpoints only. Camera
/// transforms and raster bounds remain a separate integration contract.
pub fn bounds_at(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
) -> Result<Rectangle, Error> {
self.check(session, zoom)?;
translated_bounds(position, self.bounds.size())
}
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
/// No per-label wrapping or implicit text maximum is introduced.
/// The returned Text must be consumed immediately in this session/zoom;
/// retaining raw Text past another font load/zoom change bypasses the guard.
pub fn canvas_text(
&self,
session: &RenderSession,
zoom: f32,
position: Point,
color: Color,
) -> Result<Text, Error> {
self.bounds_at(session, zoom, position)?;
Ok(Text {
content: self.displayed.clone(),
position,
color,
size: self.style.size,
line_height: self.style.line_height,
font: self.style.font,
shaping: self.style.shaping,
max_width: f32::INFINITY,
..Text::default()
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Error {
InvalidMetrics,
SourceBudget,
GraphemeBudget,
FontSystemUnavailable,
InvalidMeasurement,
InvalidPosition,
StaleZoom,
StaleRenderSession,
FontSetChanged,
}
struct Entry {
source: String,
style: Style,
zoom: f32,
width: f32,
label: Arc<Label>,
}
#[derive(Default)]
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
/// would thrash when a larger Scene is rebuilt in order. The integration owner
/// must retain these by occurrence identity and prune on removal/document change.
pub(super) struct Cache {
version: Option<graphics_text::Version>,
session: Option<Arc<()>>,
entries: VecDeque<Entry>,
#[cfg(test)]
allocations: usize,
}
impl Cache {
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
/// Cache ownership must survive redraws; constructing one per Scene defeats
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
/// a fresh renderer and a new settled session, not just remeasure this cache.
/// Error handling/fallback for these explicit experiment budgets is an
/// integration decision; never silently paint the old truncated contract.
pub fn resolve(
&mut self,
session: &RenderSession,
source: &str,
width: f32,
zoom: f32,
style: Style,
) -> Result<Arc<Label>, Error> {
let (size, line_height) = style.scaled(zoom);
if !width.is_finite()
|| width < 0.
|| !zoom.is_finite()
|| zoom <= 0.
|| !size.is_finite()
|| size <= 0.
|| !line_height.is_finite()
|| line_height <= 0.
|| !(width * zoom).is_finite()
|| !(size / zoom).is_finite()
|| !(line_height / zoom).is_finite()
{
return Err(Error::InvalidMetrics);
}
if source.len() > MAX_SOURCE_BYTES {
return Err(Error::SourceBudget);
}
let mut fonts = graphics_text::font_system()
.write()
.map_err(|_| Error::FontSystemUnavailable)?;
if session.version != fonts.version() {
return Err(Error::FontSetChanged);
}
if self.version != Some(fonts.version())
|| self
.session
.as_ref()
.is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity))
{
self.entries.clear();
self.version = Some(fonts.version());
self.session = Some(session.identity.clone());
}
if let Some(index) = self.entries.iter().position(|entry| {
entry.source == source
&& entry.style == style
&& entry.zoom == zoom
&& entry.width == width
}) {
let entry = self.entries.remove(index).unwrap();
let label = entry.label.clone();
self.entries.push_back(entry);
return Ok(label);
}
let normalized = Normalized::new(source);
let cuts: Vec<usize> = normalized
.text
.grapheme_indices(true)
.map(|(i, _)| i)
.chain(std::iter::once(normalized.text.len()))
.collect();
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES {
return Err(Error::GraphemeBudget);
}
// Candidate buffers are temporary. Only the resolved strings/metrics
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
let mut shapes = graphics_text::Cache::new();
let mut measure = |content: &str| -> Result<Size, Error> {
if content.is_empty() {
return Ok(Size::ZERO);
}
#[cfg(test)]
{
self.allocations += 1;
}
let (_, entry) = shapes.allocate(
fonts.raw(),
graphics_text::cache::Key {
content,
size,
line_height,
font: style.font,
bounds: Size::INFINITE,
shaping: style.shaping,
align_x: Alignment::Default,
},
);
if entry.buffer.layout_runs().count() != 1
|| !entry.min_bounds.width.is_finite()
|| !entry.min_bounds.height.is_finite()
{
return Err(Error::InvalidMeasurement);
}
world_size(entry.min_bounds, zoom)
};
let full = measure(&normalized.text)?;
let (displayed, retained, measured) = if full.width <= width {
(normalized.text.clone(), normalized.text.len(), full)
} else {
let ellipsis = measure("…")?;
if ellipsis.width > width {
(String::new(), 0, Size::ZERO)
} else {
// Every accepted candidate is actually shaped and fits. Search
// is bounded/logarithmic; contextual shaping is not universally
// monotone, so this does NOT promise the globally longest prefix.
// Full content is always checked first and never needlessly cut.
let mut best = ("…".to_owned(), 0, ellipsis);
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
while low < high {
let middle = low + (high - low) / 2;
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
let measured = measure(&candidate)?;
if measured.width <= width {
best = (candidate, cuts[middle], measured);
low = middle + 1;
} else {
high = middle;
}
}
best
}
};
let label = Arc::new(Label {
source: source.into(),
displayed,
retained_source: 0..normalized.source_end(retained),
bounds: Rectangle::with_size(measured),
style,
measured_session: session.clone(),
measured_zoom: zoom,
});
if self.entries.len() == MAX_ENTRIES {
self.entries.pop_front();
}
self.entries.push_back(Entry {
source: source.into(),
style,
zoom,
width,
label: label.clone(),
});
Ok(label)
}
}
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
if !world.width.is_finite()
|| !world.height.is_finite()
|| world.width < 0.
|| world.height < 0.
{
return Err(Error::InvalidMeasurement);
}
Ok(world)
}
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
if !position.x.is_finite()
|| !position.y.is_finite()
|| !(position.x + size.width).is_finite()
|| !(position.y + size.height).is_finite()
{
return Err(Error::InvalidPosition);
}
Ok(Rectangle::new(position, size))
}
struct Normalized {
text: String,
ends: Vec<(usize, usize)>,
}
impl Normalized {
fn new(source: &str) -> Self {
let mut result = Self {
text: String::new(),
ends: vec![(0, 0)],
};
let mut separator = false;
for (start, c) in source.char_indices() {
if c.is_whitespace() {
separator = !result.text.is_empty();
continue;
}
if separator {
result.text.push(' ');
result.ends.push((result.text.len(), start));
separator = false;
}
result.text.push(c);
result.ends.push((result.text.len(), start + c.len_utf8()));
}
result
}
fn source_end(&self, normalized_end: usize) -> usize {
self.ends
.binary_search_by_key(&normalized_end, |(end, _)| *end)
.map(|index| self.ends[index].1)
.expect("grapheme cuts are character boundaries")
}
}
#[cfg(test)]
#[path = "scenario_label/tests.rs"]
mod tests;
@@ -0,0 +1,231 @@
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
//!
//! The caller supplies a finite structural label band AFTER endpoint/activation
//! alignment, with padding already removed from its edges. Blockers include
//! padded activation/card/header bounds over the complete text band, including
//! stroke/ink clearance. This function does not infer a self-message corridor,
//! alter scene extents, change zoom, or establish raster-ink containment.
use iced::Rectangle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct InvalidGeometry;
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
let right = rect.x + rect.width;
let bottom = rect.y + rect.height;
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
.into_iter()
.any(|value| !value.is_finite())
|| rect.width < 0.
|| rect.height < 0.
{
return Err(InvalidGeometry);
}
Ok((rect.x, right, rect.y, bottom))
}
/// Select one connected clear interval; never assemble text across blockers.
/// Prefer greater width, then proximity to the previous label anchor, then the
/// leftmost interval. Input order has no influence. A zero-area band has no
/// corridor. Touching the padded vertical band is conservatively obstructing;
/// a zero-area blocker has no area and must receive stroke padding upstream.
pub(super) fn widest_clear(
band: Rectangle,
blockers: &[Rectangle],
preferred_anchor: f32,
) -> Result<Option<Rectangle>, InvalidGeometry> {
let (left, right, top, bottom) = edges(band)?;
if !preferred_anchor.is_finite() {
return Err(InvalidGeometry);
}
let mut blocked = Vec::new();
for &blocker in blockers {
let (x0, x1, y0, y1) = edges(blocker)?;
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
let interval = (x0.max(left), x1.min(right));
if interval.0 < interval.1 {
blocked.push(interval);
}
}
}
if band.width == 0. || band.height == 0. {
return Ok(None);
}
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
let mut best: Option<Rectangle> = None;
let mut consider = |start: f32, end: f32| {
if start >= end {
return;
}
let candidate = Rectangle {
x: start,
width: contained_width(start, end),
..band
};
// Use f64 only for comparison distance: subtracting two admitted f32
// world coordinates can overflow even though each rectangle is finite.
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
if best.is_none_or(|old| {
candidate.width > old.width
|| (candidate.width == old.width
&& (distance(candidate.x) < distance(old.x)
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
}) {
best = Some(candidate);
}
};
let mut cursor = left;
for (start, end) in blocked {
consider(cursor, start);
cursor = cursor.max(end);
}
consider(cursor, right);
Ok(best)
}
/// Finite f32 endpoints can subtract to infinity or round across a blocker.
/// Compute in f64, cap to finite f32, and round inward if nearest rounding
/// overshot the admitted interval's end.
fn contained_width(start: f32, end: f32) -> f32 {
let exact = f64::from(end) - f64::from(start);
let mut width = exact.min(f64::from(f32::MAX)) as f32;
if f64::from(start) + f64::from(width) > f64::from(end) {
width = width.next_down();
}
width
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
Rectangle {
x,
y,
width,
height,
}
}
#[test]
fn long_message_does_not_cross_an_intermediate_execution() {
let band = rect(20., 100., 700., 22.);
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
assert_eq!(result, rect(20., 100., 230., 22.));
for blocker in blockers {
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
}
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
#[test]
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
let band = rect(-100., 10., 600., 20.);
let blockers = [
rect(50., 0., 80., 60.),
rect(0., 5., 70., 30.),
rect(450., 0., 100., 60.),
];
let expected = Some(rect(130., 10., 320., 20.));
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
let mut reversed = blockers;
reversed.reverse();
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
}
#[test]
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
let band = rect(0., 100., 200., 20.);
// The first blocker ends at the band top: checking only a baseline
// would miss it. The second starts at the band bottom.
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
assert_eq!(
widest_clear(band, &blockers, 0.).unwrap(),
Some(rect(60., 100., 90., 20.))
);
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
}
#[test]
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
let band = rect(0., 0., 300., 20.);
let blockers = [rect(100., 0., 100., 20.)];
assert_eq!(
widest_clear(band, &blockers, 190.).unwrap(),
Some(rect(200., 0., 100., 20.))
);
assert_eq!(
widest_clear(band, &blockers, 100.).unwrap(),
Some(rect(0., 0., 100., 20.))
);
}
#[test]
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
// Direction and self-loop policy are explicit caller inputs. Neither
// a zero endpoint gap nor screen width can silently supply a corridor.
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
}
assert_eq!(
widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(),
None
);
assert_eq!(
widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(),
None
);
}
#[test]
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
let band = rect(0., 0., 100., 20.);
for invalid in [
rect(f32::NAN, 0., 10., 20.),
rect(0., 0., -1., 20.),
rect(f32::MAX, 0., f32::MAX, 20.),
rect(0., f32::MAX, 10., f32::MAX),
] {
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
}
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
}
#[test]
fn finite_band_cannot_reconstruct_an_infinite_width() {
let band = rect(f32::from_bits(0xfe79636e), 0., f32::MAX, 20.);
let right = band.x + band.width;
assert!(right.is_finite());
assert!(
(right - band.x).is_infinite(),
"counterexample must exercise f32 subtraction overflow"
);
let result = widest_clear(band, &[], 0.).unwrap().unwrap();
assert!(result.width.is_finite() && result.width > 0.);
assert!(result.x + result.width <= right);
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(right));
}
#[test]
fn reconstructed_width_rounds_inward_at_a_blocker() {
let band = rect(-999_948.562_5, 0., 1_000_000., 20.);
let blocker = rect(2.1, 0., 10., 20.);
assert!(
band.x + (blocker.x - band.x) > blocker.x,
"counterexample must cross the blocker under naive reconstruction"
);
let result = widest_clear(band, &[blocker], 0.).unwrap().unwrap();
assert_eq!(result.x, band.x);
assert!(result.width > 999_000.);
assert!(result.x + result.width <= blocker.x);
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(blocker.x));
}
}
@@ -0,0 +1,487 @@
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
use super::*;
use iced::advanced::{
graphics::text::Paragraph,
text::{Paragraph as _, Text as ParagraphText, Wrapping},
};
// Independent paragraph construction: no candidate cache, normalization, search,
// or character-width estimate is used by this measurement oracle.
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
let size = Pixels(style.size.0 * zoom);
let line_height = match style.line_height {
LineHeight::Relative(factor) => LineHeight::Relative(factor),
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
};
let paragraph = Paragraph::with_text(ParagraphText {
content,
bounds: Size::INFINITE,
size,
line_height,
font: style.font,
align_x: Alignment::Default,
align_y: iced::alignment::Vertical::Top,
shaping: style.shaping,
wrapping: Wrapping::default(),
});
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
}
#[test]
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style::default();
for text in [
"1. call · Request valve repair · Technician service conversation",
"2. reply · Return pressure after repair · Technician service conversation",
"1. call · Request redundant hydraulic pressure calibration channel A",
"1. call · Request redundant hydraulic pressure calibration channel B",
] {
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
assert_ne!(narrow.displayed, text);
assert!(narrow.bounds.width <= 80.);
assert_eq!(
narrow.bounds.size(),
paragraph_size(&narrow.displayed, 1., style)
);
}
}
#[test]
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
let expected = "1. call · Pump · Operation: Need\u{301}le";
let n = Normalized::new(raw);
assert_eq!(n.text, expected);
let prefix = "1. call · Pump ";
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let wide = cache
.resolve(&session, raw, 1600., 1., Style::default())
.unwrap();
assert_eq!(wide.displayed, expected);
assert_eq!(wide.retained_source.end, raw.trim_end().len());
let field = raw.find("Need").unwrap()..raw.trim_end().len();
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
assert!(
!wide.retains_any(&session, 1., 0..2).unwrap(),
"normalized whitespace is not a visible field"
);
let small = cache
.resolve(&session, raw, 70., 1., Style::default())
.unwrap();
assert!(!small.retains_any(&session, 1., field).unwrap());
assert!(small.retained_source.end < raw.find("Operation").unwrap());
}
#[test]
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
let text = "call · 戻り値 圧力 e\u{301} 👩‍🔬 safety valve verification";
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for zoom in [0.66, 1., 1.75] {
let label = cache
.resolve(&session, text, 105., zoom, Style::default())
.unwrap();
assert!(label.bounds.width <= 105.);
assert_eq!(
label.bounds.size(),
paragraph_size(&label.displayed, zoom, Style::default())
);
assert!(
text.grapheme_indices(true)
.any(|(i, _)| i == label.retained_source.end)
|| label.retained_source.end == text.len()
);
let painted = label
.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK)
.unwrap();
assert_eq!(painted.content, label.displayed);
assert!(painted.max_width.is_infinite());
assert_eq!(
label
.bounds_at(&session, zoom, painted.position)
.unwrap()
.size(),
label.bounds.size()
);
}
}
#[test]
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for text in ["", " \t\n", "Operation: Pump"] {
let label = cache
.resolve(&session, text, 0., 1., Style::default())
.unwrap();
assert_eq!(label.displayed, "");
assert_eq!(label.retained_source, 0..0);
assert_eq!(label.bounds.size(), Size::ZERO);
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
}
}
#[test]
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification with a descriptive operation label";
let style = Style::default();
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
let measured = cache.allocations;
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
assert!(Arc::ptr_eq(&a, &b));
assert_eq!(cache.allocations, measured);
for (width, zoom, style) in [
(120., 1., style),
(100., 0.66, style),
(
100.,
1.,
Style {
size: Pixels(14.),
..style
},
),
] {
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
assert!(!Arc::ptr_eq(&a, &other));
}
}
#[test]
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
let mut cache = Cache::default();
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Pressure verification";
let a = cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(
&a,
&cache
.resolve(&old_session, text, 100., 1., Style::default())
.unwrap()
));
assert_eq!(cache.allocations, measured);
// A fresh renderer session invalidates a still-resident entry. This does
// not mutate global fonts or purport to test dynamic WGPU font loading.
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let refreshed = cache
.resolve(&new_session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let text = "call · Resident pressure label";
let a = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
let measured = cache.allocations;
assert!(measured > 0);
// Exercise the local memo epoch branch while a is still resident. No
// intervening width/zoom/style resolution can cause incidental eviction.
// This is not an actual font-load or renderer-cache acceptance test.
cache.version = None;
let refreshed = cache
.resolve(&session, text, 100., 1., Style::default())
.unwrap();
assert!(!Arc::ptr_eq(&a, &refreshed));
assert!(cache.allocations > measured);
assert_eq!(cache.entries.len(), 1);
assert!(Arc::ptr_eq(
&cache.entries.front().unwrap().label,
&refreshed
));
}
#[test]
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "Operation: Pressure", 200., 1., Style::default())
.unwrap();
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (context, zoom, expected) in [
(&session, 0.66, Error::StaleZoom),
(&other, 1., Error::StaleRenderSession),
] {
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
assert_eq!(
label.retains_any(context, zoom, 0..9).unwrap_err(),
expected
);
assert_eq!(
label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(),
expected
);
assert_eq!(
label
.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK)
.unwrap_err(),
expected
);
}
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
}
#[test]
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let style = Style {
size: Pixels(2.),
line_height: LineHeight::Relative(f32::MAX),
..Style::default()
};
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
// required world line height is not; reject before passing it to shaping.
let scaled = style.scaled(0.5);
assert!(scaled.0.is_finite() && scaled.1.is_finite());
assert_eq!(
cache
.resolve(&session, "call", 100., 0.5, style)
.unwrap_err(),
Error::InvalidMetrics
);
assert_eq!(cache.allocations, 0);
assert!(cache.entries.is_empty());
// Independently exercise validation after division for actual measured
// dimensions: finite scaled width/height do not imply finite world values.
assert_eq!(
world_size(Size::new(f32::MAX, 1.), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(1., f32::MAX), 0.5),
Err(Error::InvalidMeasurement)
);
assert_eq!(
world_size(Size::new(20., 8.), 0.5).unwrap(),
Size::new(40., 16.)
);
}
#[test]
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
assert_eq!(
cache
.resolve(&session, "call", width, zoom, Style::default())
.unwrap_err(),
Error::InvalidMetrics
);
}
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_SOURCE_BYTES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::SourceBudget
);
assert_eq!(
cache
.resolve(
&session,
&"x".repeat(MAX_GRAPHEMES + 1),
100.,
1.,
Style::default()
)
.unwrap_err(),
Error::GraphemeBudget
);
assert!(cache.entries.is_empty());
}
#[test]
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let label = cache
.resolve(&session, "call", 100., 1., Style::default())
.unwrap();
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
assert_eq!(
label.bounds_at(&session, 1., position).unwrap_err(),
Error::InvalidPosition
);
assert_eq!(
label
.canvas_text(&session, 1., position, Color::BLACK)
.unwrap_err(),
Error::InvalidPosition
);
}
assert_eq!(
translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)),
Err(Error::InvalidPosition)
);
assert_eq!(
translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)),
Err(Error::InvalidPosition)
);
assert_eq!(
label
.bounds_at(&session, 1., Point::new(-20., -10.))
.unwrap()
.position(),
Point::new(-20., -10.)
);
}
/// Must run as the ONLY selected test in a dedicated, fresh test process.
/// The ignored marker keeps this global-font mutation out of normal parallel
/// desktop tests. See the evidence directory's exact executable runner.
#[test]
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
fn actual_font_load_rejects_retained_label_and_old_session() {
use graphics_text::cosmic_text::fontdb;
use std::borrow::Cow;
let mut cache = Cache::default();
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
let source = "Operation: Pressure";
let label = cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap();
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
assert!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.is_ok()
);
let measured = cache.allocations;
assert!(measured > 0);
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
let (version_before, version_after, faces_before, faces_after) = {
let mut fonts = graphics_text::font_system().write().unwrap();
let face = fonts
.raw()
.db()
.faces()
.find(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face")
.id;
let bytes = fonts
.raw()
.db()
.with_face_data(face, |data, _| data.to_vec())
.expect("the built-in binary font has accessible bytes");
assert!(!bytes.is_empty());
// load_font increments Version even for invalid bytes. Independently
// establish actual face admission so this cannot pass with invalid data.
let mut probe = fontdb::Database::new();
probe.load_font_data(bytes.clone());
let admitted = probe
.faces()
.filter(|face| {
face.families
.iter()
.any(|(family, _)| family == "Iced-Icons")
})
.count();
assert!(
admitted > 0,
"the extracted bytes parse as the expected real font"
);
let before = fonts.version();
assert_eq!(before, session.version);
let count = fonts.raw().db().faces().count();
// Owned bytes intentionally bypass Borrowed-address deduplication.
// This is an actual valid duplicate-face load, not a claim the chosen
// face or rendered metrics change; the stale-epoch rule is the oracle.
fonts.load_font(Cow::Owned(bytes));
(
before,
fonts.version(),
count,
fonts.raw().db().faces().count(),
)
};
assert_ne!(version_before, version_after);
assert!(
faces_after > faces_before,
"actual font admission must accompany the version increment"
);
assert_eq!(
label.displayed(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retained_source(&session, 1.).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.retains_any(&session, 1., 0..9).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
label
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache
.resolve(&session, source, 200., 1., Style::default())
.unwrap_err(),
Error::FontSetChanged
);
assert_eq!(
cache.allocations, measured,
"an invalid session must not remeasure and imply renderer repair"
);
println!(
"Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected"
);
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
// Process exit is the isolation boundary; no other test may run afterward.
}
@@ -0,0 +1,816 @@
//! Pure complete Scenario geometry. No persistence, gesture or fallback policy.
//! The only renderer is Scene::sequence_geometry; this module resolves its inputs.
use super::*;
use oa_core::{DiagramLayout, ScenarioLayout};
#[cfg(test)]
mod tests;
pub(crate) mod presentation;
type Candidate = BTreeMap<Id, f32>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AnchorRole {
MessageSend,
MessageReceive,
Observation,
FrameStart,
OperandStart,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Anchor {
pub(super) y: f32,
role: AnchorRole,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct MessageAnchors {
send_y: f32,
receive_y: f32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Diagnostic {
SourceOrderCrossing { scope: Id, before: Id, after: Id },
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum GeometryIssue {
UnknownAnchor(Id),
LifecycleGeometryNotSupported { lifeline: Id, scope: Id },
InvalidRepresentation(Id),
InvalidCoordinate(Id),
DuplicateCoordinate(Id),
ExpandedAddressNotSupported(Id),
CoincidentDistinctEndpoints(Id),
TypedActivationOutsideScope { call: Id, scope: Id },
TypedActivationOutsideLifetime { call: Id, receiver: Id },
TypedActivationCrossingNotSupported { call: Id, event: Id },
NonFinite(Id),
CompoundMoveNotImplemented(Id),
FixedFrameHeaderWouldMove { frame: Id },
OperandScopeOutsideEnclosingInterval { operand: Id, fragment: Id },
LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id },
LifetimeInversionNotSupported { create: Id, delete: Id },
}
#[derive(Clone, Debug)]
pub(super) struct Plan {
pub(super) anchors: BTreeMap<Id, Anchor>,
messages: BTreeMap<Id, MessageAnchors>,
pub(super) frames: BTreeMap<Id, Rectangle>,
pub(super) scopes: BTreeMap<Id, (f32, f32)>,
pub(super) height: f32,
pub(super) diagnostics: Vec<Diagnostic>,
}
pub(super) fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> {
match p.elements.get(&id)?.shared()? {
SharedBody::Occurrence { body, .. } => Some(body),
_ => None,
}
}
fn role(body: &OccurrenceBody) -> AnchorRole {
match body {
OccurrenceBody::Exchange {
message_kind: MessageKind::Create | MessageKind::Delete,
..
} => AnchorRole::MessageReceive,
OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend,
OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart,
OccurrenceBody::Branch { .. } => AnchorRole::OperandStart,
_ => AnchorRole::Observation,
}
}
fn inside(p: &Project, id: Id, owner: Id) -> bool {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
for _ in 0..p.elements.len() {
match current {
Some(parent) if parent == owner => return true,
Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent),
None => return false,
}
}
false
}
fn depth(p: &Project, id: Id) -> usize {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
let mut result = 0;
while let Some(parent) = current {
result += 1;
if result > p.elements.len() {
break;
}
current = p.elements.get(&parent).and_then(|e| e.parent);
}
result
}
pub(in crate::behavior) fn union(a: Rectangle, b: Rectangle) -> Rectangle {
let x = a.x.min(b.x);
let y = a.y.min(b.y);
Rectangle::new(
Point::new(x, y),
Size::new(
(a.x + a.width).max(b.x + b.width) - x,
(a.y + a.height).max(b.y + b.height) - y,
),
)
}
/// Actual named shapes/message paths supply footprint measurements; activation
/// bars are intentionally excluded because current Scene derives them later.
fn footprints(
scene: &Scene,
anchors: &BTreeMap<Id, Anchor>,
seed: &BTreeMap<Id, Anchor>,
) -> BTreeMap<Id, Rectangle> {
let mut out = BTreeMap::new();
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
let Some(anchor) = anchors.get(&item.id) else {
continue;
};
let mut rect = item.rect;
rect.y += anchor.y - seed[&item.id].y;
out.insert(item.id, rect);
}
for line in &scene.lines {
let Some(anchor) = anchors.get(&line.id) else {
continue;
};
let delta = anchor.y - seed[&line.id].y;
let mut rect = line.label_bounds();
rect.y += delta;
for point in &line.points {
rect = union(
rect,
Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO),
);
}
out.entry(line.id)
.and_modify(|old| *old = union(*old, rect))
.or_insert(rect);
}
out
}
fn include_ancestors(p: &Project, id: Id, rect: Rectangle, covered: &mut BTreeMap<Id, Rectangle>) {
let mut current = p.elements.get(&id).and_then(|e| e.parent);
for _ in 0..p.elements.len() {
let Some(parent) = current else {
break;
};
covered
.entry(parent)
.and_modify(|old| *old = union(*old, rect))
.or_insert(rect);
current = p.elements.get(&parent).and_then(|e| e.parent);
}
}
fn coverage(p: &Project, footprints: &BTreeMap<Id, Rectangle>) -> BTreeMap<Id, Rectangle> {
let mut covered = BTreeMap::new();
for (&id, &rect) in footprints {
include_ancestors(p, id, rect, &mut covered);
}
covered
}
impl Plan {
/// Authored endpoint values are primary inputs, not deltas reconstructed from
/// rounded rows. Lifecycle alignment owns x and marker attachment; this final
/// y alignment precedes typed activation derivation and preserves send bits.
pub(super) fn align_message_anchors(&self, lines: &mut [Line]) {
for line in lines {
let Some(message) = self.messages.get(&line.id) else {
continue;
};
let previous_send = line.points.first().map_or(message.send_y, |point| point.y);
line.label_at.y += message.send_y - previous_send;
let last = line.points.len().saturating_sub(1);
for (index, point) in line.points.iter_mut().enumerate() {
point.y = if index == last || (last == 3 && index == 2) {
message.receive_y
} else {
message.send_y
};
}
}
}
fn build(
p: &Project,
owner: Id,
baseline: &Scene,
desired: &Candidate,
) -> Result<Self, GeometryIssue> {
let mut seed: BTreeMap<_, _> = baseline
.rows
.iter()
.map(|(id, (_, y))| {
(
*id,
Anchor {
y: *y,
role: role(occurrence(p, *id).expect("Scene occurrence")),
},
)
})
.collect();
// Operand headers are real Scene boxes, but are not entries in rows.
for item in &baseline.boxes {
if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) {
seed.insert(
item.id,
Anchor {
y: item.rect.y,
role: AnchorRole::OperandStart,
},
);
}
}
let mut anchors = seed.clone();
for (&id, &y) in desired {
let anchor = anchors
.get_mut(&id)
.ok_or(GeometryIssue::UnknownAnchor(id))?;
if !y.is_finite() {
return Err(GeometryIssue::NonFinite(id));
}
if matches!(
anchor.role,
AnchorRole::FrameStart | AnchorRole::OperandStart
) && y != anchor.y
{
return Err(GeometryIssue::CompoundMoveNotImplemented(id));
}
anchor.y = y;
}
let mut messages = BTreeMap::new();
for line in &baseline.lines {
if let (Some(anchor), Some(first), Some(last)) = (
anchors.get(&line.id),
line.points.first(),
line.points.last(),
) {
let message = if desired.contains_key(&line.id) {
let self_message = matches!(occurrence(p, line.id), Some(OccurrenceBody::Exchange {
sender: Some(sender), receiver, message_kind, ..
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
let lifecycle = matches!(
occurrence(p, line.id),
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Create | MessageKind::Delete,
..
})
);
if self_message && lifecycle {
MessageAnchors {
send_y: anchor.y - 24.,
receive_y: anchor.y,
}
} else {
MessageAnchors {
send_y: anchor.y,
receive_y: if self_message {
anchor.y + 24.
} else {
anchor.y
},
}
}
} else {
MessageAnchors {
send_y: first.y,
receive_y: last.y,
}
};
messages.insert(line.id, message);
}
}
// Track actual semantic lifecycle order per receiver and scope. In particular,
// a later recreation must not be compared with an earlier deletion.
for element in p
.elements
.values()
.filter(|e| e.id == owner || inside(p, e.id, owner))
{
let Some(steps) = element.steps() else {
continue;
};
let mut last = BTreeMap::new();
for id in steps {
if let (
Some(message),
Some(OccurrenceBody::Exchange {
receiver: Some(lane),
message_kind: MessageKind::Create | MessageKind::Delete,
..
}),
) = (messages.get(id), occurrence(p, *id))
&& let Some((previous, previous_y)) =
last.insert(*lane, (*id, message.receive_y))
&& previous_y > message.receive_y
{
return Err(GeometryIssue::LifetimeInversionNotSupported {
create: previous,
delete: *id,
});
}
}
}
let old_footprints = footprints(baseline, &seed, &seed);
let mut drawn = footprints(baseline, &anchors, &seed);
let old_coverage = coverage(p, &old_footprints);
let mut new_coverage = coverage(p, &drawn);
let mut frames = BTreeMap::new();
let mut containers: Vec<_> = seed
.keys()
.copied()
.filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. })))
.collect();
containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id)));
for id in containers {
let original = old_footprints[&id];
let mut rect = original;
if let Some(covered) = new_coverage.get(&id).copied() {
// Preserve automatic extents and their existing descendant padding.
// Upward envelope growth would move the painted frame header
// while its fixed FrameStart/row/slot remained at the seed.
// Reject that unsupported compound change, not mere overlap.
if covered.y < rect.y {
return Err(GeometryIssue::FixedFrameHeaderWouldMove { frame: id });
}
let old_covered = old_coverage[&id];
let padding =
(original.y + original.height - old_covered.y - old_covered.height).max(0.);
// Preserve padding before the footprint itself reaches the old
// frame edge: operand scopes already retain their own padding.
rect.height = rect
.height
.max(covered.y + covered.height + padding - rect.y);
}
frames.insert(id, rect);
drawn.insert(id, rect);
include_ancestors(p, id, rect, &mut new_coverage);
}
let mut scopes = baseline.sequence_bounds.clone();
for (&scope, bounds) in &mut scopes {
if let (Some(old), Some(new)) = (
old_coverage.get(&scope).copied(),
new_coverage.get(&scope).copied(),
) {
let top_padding = (old.y - bounds.0).max(0.);
let bottom_padding = (bounds.1 - old.y - old.height).max(0.);
if new.y < bounds.0 {
bounds.0 = new.y - top_padding;
}
bounds.1 = bounds.1.max(new.y + new.height + bottom_padding);
}
}
let tail = baseline.size.height - baseline.sequence_bounds[&owner].1;
let height = baseline.size.height.max(scopes[&owner].1 + tail);
for (&operand, &(start, end)) in &scopes {
if !matches!(occurrence(p, operand), Some(OccurrenceBody::Branch { .. })) {
continue;
}
let fragment = p.elements[&operand].parent.expect("operand fragment");
let caller = p.elements[&fragment]
.parent
.expect("fragment caller sequence");
let enclosing_frame = frames[&fragment];
let (caller_start, caller_end) = if caller == owner {
(85., height - 20.)
} else {
let (a, b) = scopes[&caller];
(a, if a == b { a + 26. } else { b })
};
let enclosing_start = anchors[&fragment].y.max(caller_start);
let enclosing_end = (enclosing_frame.y + enclosing_frame.height).min(caller_end);
let effective_end = if start == end { start + 26. } else { end };
// Actual lifecycle traversal cannot enter an operand that escapes
// this enclosing interval. This guards bridge feasibility only;
// it is not a generic event-spacing or sibling-ordering policy.
if start < enclosing_start || effective_end > enclosing_end {
return Err(GeometryIssue::OperandScopeOutsideEnclosingInterval {
operand,
fragment,
});
}
}
for (&id, message) in &messages {
if matches!(
occurrence(p, id),
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Create | MessageKind::Delete,
..
})
) {
let scope = p.elements[&id].parent.expect("lifecycle sequence owner");
// Match Rendering::new's effective scope, not the insertion
// scope: it overrides the root span to 85..height-20 and gives
// empty non-root scopes the existing 26px lifetime allowance.
let (start, end) = if scope == owner {
(85., height - 20.)
} else if let Some(&(start, end)) = scopes.get(&scope) {
(start, if start == end { start + 26. } else { end })
} else {
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
occurrence: id,
scope,
});
};
if message.receive_y < start || message.receive_y > end {
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
occurrence: id,
scope,
});
}
}
}
let mut diagnostics = Vec::new();
for element in p
.elements
.values()
.filter(|e| e.id == owner || inside(p, e.id, owner))
{
if let Some(steps) = element.steps() {
for pair in steps.windows(2) {
if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) {
if a.y > b.y {
diagnostics.push(Diagnostic::SourceOrderCrossing {
scope: element.id,
before: pair[0],
after: pair[1],
});
}
if let Some(frame) = frames.get(&pair[0])
&& frame.y + frame.height > b.y
{
diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor {
frame: pair[0],
following: pair[1],
});
}
}
}
}
}
Ok(Self {
anchors,
messages,
frames,
scopes,
height,
diagnostics,
})
}
}
/// An appearance identity, never inferred from selection or from the owner alone.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Representation {
Derived { owner: Id },
Saved { diagram: Id },
}
/// Complete immutable geometry produced from one Project borrow. Scene is retained
/// with its plan so a caller cannot accidentally render this plan against another
/// Project or representation. Document/revision freshness belongs to that caller.
pub(super) struct ResolvedScenarioPlan {
pub(super) representation: Representation,
pub(super) owner: Id,
pub(super) lanes: BTreeMap<Id, f32>,
pub(super) plan: Plan,
pub(super) latent_messages: Vec<Id>,
pub(super) bounds: Rectangle,
pub(super) scene: Scene,
}
pub(super) fn resolve(
p: &Project,
representation: Representation,
selected: Id,
) -> Result<ResolvedScenarioPlan, Vec<GeometryIssue>> {
let (owner, layout) = match representation {
Representation::Derived { owner } => (owner, None),
Representation::Saved { diagram } => {
let view = p
.diagrams
.get(&diagram)
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
let owner = view
.projection
.scenario()
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
if p.elements
.get(&owner)
.is_none_or(|e| e.layer() != view.layer)
{
return Err(vec![GeometryIssue::InvalidRepresentation(diagram)]);
}
let layout = view
.layout
.as_ref()
.map(|DiagramLayout::Scenario(layout)| layout);
(owner, layout)
}
};
if p.elements
.get(&owner)
.is_none_or(|e| e.kind() != Kind::Scenario)
{
return Err(vec![GeometryIssue::InvalidRepresentation(owner)]);
}
let empty = ScenarioLayout::default();
let layout = layout.unwrap_or(&empty);
let mut lanes = BTreeMap::new();
let mut issues = Vec::new();
for lane in &layout.lanes {
if p.elements
.get(&lane.lifeline)
.is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(owner))
{
issues.push(GeometryIssue::UnknownAnchor(lane.lifeline));
}
if !lane.x.is_finite() || lane.x.abs() > oa_core::MAX_COORDINATE {
issues.push(GeometryIssue::InvalidCoordinate(lane.lifeline));
}
if lanes.insert(lane.lifeline, lane.x).is_some() {
issues.push(GeometryIssue::DuplicateCoordinate(lane.lifeline));
}
}
if !issues.is_empty() {
return Err(issues);
}
// The actual automatic traversal runs first, with authored lane centers already
// applied. It supplies every untouched anchor, shape, insertion scope and label.
let baseline = Scene::sequence_geometry(
p,
owner,
selected,
(!lanes.is_empty()).then_some(&lanes),
None,
);
let mut desired = BTreeMap::new();
let mut seen = std::collections::BTreeSet::new();
let mut latent_messages = Vec::new();
let line_ids: std::collections::BTreeSet<_> =
baseline.lines.iter().map(|line| line.id).collect();
for message in &layout.messages {
let id = message.address.occurrence;
if !seen.insert(id) {
issues.push(GeometryIssue::DuplicateCoordinate(id));
}
if !message.address.references.is_empty() {
issues.push(GeometryIssue::ExpandedAddressNotSupported(id));
}
if !message.send_y.is_finite() || message.send_y.abs() > oa_core::MAX_COORDINATE {
issues.push(GeometryIssue::InvalidCoordinate(id));
}
let Some(OccurrenceBody::Exchange {
sender,
receiver,
message_kind,
..
}) = occurrence(p, id)
else {
issues.push(GeometryIssue::UnknownAnchor(id));
continue;
};
if !baseline.rows.contains_key(&id) {
issues.push(GeometryIssue::UnknownAnchor(id));
continue;
}
if !line_ids.contains(&id) {
latent_messages.push(id);
continue;
}
// Legacy lifecycle rows denote receipt, whereas the saved field denotes send.
let row = if sender.is_some()
&& sender == receiver
&& matches!(message_kind, MessageKind::Create | MessageKind::Delete)
{
message.send_y + 24.
} else {
message.send_y
};
desired.insert(id, row);
}
let actual_lanes = &baseline.render_lanes;
// A coincident pair is not a self message. Do not invent an arrow or lane order.
for line in &baseline.lines {
if let Some(OccurrenceBody::Exchange {
sender: Some(sender),
receiver,
message_kind,
..
}) = occurrence(p, line.id)
{
let target = if matches!(message_kind, MessageKind::Timer { .. }) {
Some(*sender)
} else {
*receiver
};
if target.is_some_and(|target| target != *sender)
&& target
.is_some_and(|target| actual_lanes.get(sender) == actual_lanes.get(&target))
{
issues.push(GeometryIssue::CoincidentDistinctEndpoints(line.id));
}
}
}
if !issues.is_empty() {
return Err(issues);
}
let mut plan = Plan::build(p, owner, &baseline, &desired).map_err(|issue| vec![issue])?;
// Keep the schema's exact send value; send+24 is performed once. In particular,
// a self Create/Delete receive row must never round-trip back into saved send.
for message in &layout.messages {
let id = message.address.occurrence;
if let Some(anchors) = plan.messages.get_mut(&id) {
let self_message = matches!(occurrence(p, id), Some(OccurrenceBody::Exchange {
sender: Some(sender), receiver, message_kind, ..
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
*anchors = MessageAnchors {
send_y: message.send_y,
receive_y: if self_message {
message.send_y + 24.
} else {
message.send_y
},
};
}
}
// Typed bars require a positive receiver interval. This is a narrow renderer
// limitation, not a global ban on reordered/negative asynchronous anchors.
for (&id, message) in &plan.messages {
match occurrence(p, id) {
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
}) => {
let scope = baseline.rows[&id].0;
let (start, end) = if scope == owner {
(85., plan.height - 20.)
} else {
plan.scopes[&scope]
};
if desired.contains_key(&id) && (message.send_y < start || message.receive_y >= end)
{
issues.push(GeometryIssue::TypedActivationOutsideScope { call: id, scope });
}
}
Some(OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(call),
..
}) => {
if let Some(start) = plan.messages.get(call)
&& (desired.contains_key(&id) || desired.contains_key(call))
&& message.send_y <= start.receive_y
{
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
call: *call,
event: id,
});
}
}
_ => {}
}
}
if !issues.is_empty() {
return Err(issues);
}
for segment in &baseline.activations {
if baseline
.rows
.get(&segment.call)
.is_none_or(|(scope, _)| *scope != segment.scope)
{
continue;
}
let Some(start) = plan.messages.get(&segment.call) else {
continue;
};
let event = match segment.finish {
scenario_activation::Finish::Reply(event)
| scenario_activation::Finish::Terminated(event) => Some(event),
_ => None,
};
if let Some(event) = event
&& (desired.contains_key(&event) || desired.contains_key(&segment.call))
&& plan.anchors[&event].y <= start.receive_y
{
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
call: segment.call,
event,
});
}
}
if !issues.is_empty() {
return Err(issues);
}
let scene = Scene::sequence_geometry(
p,
owner,
selected,
(!lanes.is_empty()).then_some(&lanes),
Some(&plan),
);
// Compare the actual projector, whose cursor rules include sibling scopes and
// lifecycle/fragment events. Retain existing incomplete-model uncertainty;
// reject only newly missing geometry introduced by this presentation.
let previous_missing = baseline.lifecycle.missing_geometry();
for (lifeline, scope) in scene
.lifecycle
.missing_geometry()
.difference(&previous_missing)
{
issues.push(GeometryIssue::LifecycleGeometryNotSupported {
lifeline: *lifeline,
scope: *scope,
});
}
if !issues.is_empty() {
return Err(issues);
}
for segment in &baseline.activations {
let Some(seed) = baseline
.lines
.iter()
.find(|line| line.id == segment.call)
.and_then(|line| line.points.last())
else {
continue;
};
if segment.rect.y != seed.y || baseline.rows[&segment.call].0 != segment.scope {
continue;
}
let receive = plan.messages[&segment.call].receive_y;
if !desired.is_empty()
&& !scene.activations.iter().any(|current| {
current.call == segment.call
&& current.scope == segment.scope
&& current.rect.y == receive
})
{
issues.push(GeometryIssue::TypedActivationOutsideLifetime {
call: segment.call,
receiver: segment.receiver,
});
}
}
if !issues.is_empty() {
return Err(issues);
}
let bounds = structural_bounds(&scene);
let all_lanes = scene.render_lanes.clone();
Ok(ResolvedScenarioPlan {
representation,
owner,
lanes: all_lanes,
plan,
latent_messages,
bounds,
scene,
})
}
fn structural_bounds(scene: &Scene) -> Rectangle {
let mut bounds = Rectangle::new(scene.layout_origin, scene.size);
for item in &scene.boxes {
bounds = union(bounds, item.rect);
}
for line in &scene.lines {
if !line.label.is_empty() {
bounds = union(bounds, line.label_bounds());
}
for point in &line.points {
// Arrow wings extend beyond the centerline in either direction.
bounds = union(
bounds,
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
);
}
}
for segment in &scene.activations {
bounds = union(bounds, segment.rect);
}
for point in &scene.open_calls {
bounds = union(
bounds,
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
);
}
if let Some(lifetime) = scene.lifecycle.bounds() {
bounds = union(bounds, lifetime);
}
bounds
}
@@ -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,
},
},
}
}
}
}
pub(crate) 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; }; }; }
}
"#;
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,7 @@ struct EventMarker {
capsule: Rectangle,
name: String,
sender_x: Option<f32>,
self_message: bool,
}
#[derive(Debug, Default, Clone, PartialEq)]
@@ -89,7 +90,7 @@ impl Rendering {
continue;
};
for event in &lane.markers {
let sender_x = project
let sender_id = project
.elements
.get(&event.occurrence)
.and_then(|e| e.shared())
@@ -101,7 +102,7 @@ impl Rendering {
..
},
..
} => lanes.get(sender).copied(),
} => Some(*sender),
_ => None,
});
markers.push(EventMarker {
@@ -113,7 +114,8 @@ impl Rendering {
Size::new(140., 28.),
),
name: project.named(lane.lifeline).to_owned(),
sender_x,
sender_x: sender_id.and_then(|id| lanes.get(&id).copied()),
self_message: sender_id == Some(lane.lifeline),
});
}
}
@@ -138,7 +140,7 @@ impl Rendering {
};
let x = marker.center.x;
let y = marker.center.y;
let self_message = sender == x;
let self_message = marker.self_message;
let end_x = match marker.event.kind {
MarkerKind::Birth if sender < x => marker.capsule.x,
MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
@@ -166,6 +168,48 @@ impl Rendering {
}
}
/// Geometry loss is distinct from warning-admitted semantic uncertainty.
/// The saved bridge compares these exact projector diagnostics with baseline.
pub(in crate::behavior) fn missing_geometry(&self) -> std::collections::BTreeSet<(Id, Id)> {
self.lifetimes
.iter()
.flat_map(|lane| {
lane.uncertainties
.iter()
.filter(|issue| issue.reason == super::UncertaintyReason::MissingGeometry)
.map(|issue| (lane.lifeline, issue.scope))
})
.collect()
}
/// Complete structural lifetime bounds, including the birth capsule and death mark.
pub(in crate::behavior) fn bounds(&self) -> Option<Rectangle> {
let mut bounds = self.span.and_then(|span| {
self.lanes
.values()
.map(|x| {
Rectangle::new(
Point::new(*x, span.start),
Size::new(0., span.end - span.start),
)
})
.reduce(crate::behavior::scenario_layout::union)
});
for marker in &self.markers {
let rect = match marker.event.kind {
MarkerKind::Birth => marker.capsule,
MarkerKind::Death => Rectangle::new(
Point::new(marker.center.x - 10., marker.center.y - 10.),
Size::new(20., 20.),
),
};
bounds = Some(bounds.map_or(rect, |old| {
crate::behavior::scenario_layout::union(old, rect)
}));
}
bounds
}
pub(in crate::behavior) fn alive_spans(
&self,
lifeline: Id,
@@ -443,8 +443,8 @@ fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without
let birth = f.message(f.owner, MessageKind::Create);
let c1 = f.message(f.owner, MessageKind::Call);
let c2 = f.message(f.owner, MessageKind::Call);
f.reply(f.owner, c2);
f.reply(f.owner, c1);
let r2 = f.reply(f.owner, c2);
let r1 = f.reply(f.owner, c1);
let death = f.message(f.owner, MessageKind::Delete);
let document = f.document();
let before = document.snapshot();
@@ -454,6 +454,14 @@ fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without
});
assert_eq!(moved.rows[&birth].1, baseline.rows[&birth].1 + 60.);
assert_eq!(moved.rows[&death].1, baseline.rows[&death].1 + 60.);
for scene in [&baseline, &moved] {
assert_eq!(scene.activations.len(), 2);
for (call, reply) in [(c1, r1), (c2, r2)] {
assert!(scene.activations.iter().any(|segment| {
segment.call == call && segment.finish == Finish::Reply(reply)
}));
}
}
for (a, b) in baseline.activations.iter().zip(&moved.activations) {
assert_eq!(
(a.call, a.receiver, a.scope, a.column, a.finish),
@@ -1796,6 +1796,7 @@ mod tests {
layer: Layer::Sa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
view_options: Default::default(),
},
@@ -294,6 +294,7 @@ pub fn create_view(project: &Project, machine: Id) -> Result<Plan, ModelError> {
layer: owner.layer(),
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
view_options: Default::default(),
},
@@ -219,6 +219,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
graph.interaction = Some((context, state.tool, clean));
let scale = graph.scale;
let scope = super::super::canvas_workspace::Scope {
scenario_view: None,
document: context.document,
owner,
revision: context.revision,
+110 -12
View File
@@ -1729,11 +1729,32 @@ fn hex(c: Color) -> String {
(c.b * 255.) as u8
)
}
pub fn export_svg(p: &Project, diagram: Id) -> String {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportError {
MissingDiagram(Id),
UnsupportedScenario(Id),
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingDiagram(id) => write!(f, "Cannot export: diagram {id} no longer exists."),
Self::UnsupportedScenario(_) => {
f.write_str("SVG export of saved Scenario views is not available yet.")
}
}
}
}
impl std::error::Error for ExportError {}
pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
let d = p
.diagrams
.get(&diagram)
.ok_or(ExportError::MissingDiagram(diagram))?;
if d.projection.scenario().is_some() {
return Err(ExportError::UnsupportedScenario(diagram));
}
let view = project_view(p, diagram);
let Some(d) = p.diagrams.get(&diagram) else {
return String::new();
};
let routed = route_diagram(p, d, &view);
let left = routed.bounds.position.x - 30.;
let top = routed.bounds.position.y - 30.;
@@ -1872,7 +1893,7 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
svg.push_str("</g>");
}
svg.push_str("</svg>");
svg
Ok(svg)
}
#[cfg(test)]
@@ -1882,6 +1903,83 @@ mod pointer_tests;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn svg_rejects_saved_scenario_with_absent_or_authored_layout_without_mutation() {
let f = crate::behavior::scenario::direct_tests::fixture();
let mut p = f.project;
let diagram = p.ensure_diagram(Layer::Oa);
let d = p.diagrams.get_mut(&diagram).unwrap();
d.projection = ProjectionKind::Scenario { scenario: f.owner };
d.nodes.clear();
d.routes.clear();
for layout in [
None,
Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: f.sender,
x: 240.,
}],
messages: vec![ScenarioMessagePosition {
address: OccurrenceEndpoint {
references: vec![],
occurrence: f.call,
},
send_y: 180.,
}],
})),
] {
p.diagrams.get_mut(&diagram).unwrap().layout = layout;
let admitted = execute_batch(&p, &[]).expect("valid saved Scenario fixture");
assert_eq!(admitted, p);
let before = p.clone();
assert_eq!(
export_svg(&p, diagram),
Err(ExportError::UnsupportedScenario(diagram))
);
assert_eq!(p, before);
}
}
#[test]
fn svg_rejects_missing_diagram_without_mutation() {
let p = Project::demo();
let missing = Id::new_v4();
let before = p.clone();
assert_eq!(
export_svg(&p, missing),
Err(ExportError::MissingDiagram(missing))
);
assert_eq!(p, before);
}
#[test]
fn svg_accepts_populated_and_empty_architecture_views() {
let mut p = Project::demo();
let diagram = p
.diagrams
.values()
.find(|d| d.projection.is_architecture() && !d.nodes.is_empty())
.unwrap()
.id;
let before = p.clone();
let svg = export_svg(&p, diagram).expect("populated architecture");
assert!(svg.starts_with("<svg "));
assert!(svg.contains("<text "));
assert!(svg.ends_with("</svg>"));
assert_eq!(p, before);
let d = p.diagrams.get_mut(&diagram).unwrap();
d.projection = ProjectionKind::Overview;
d.nodes.clear();
d.routes.clear();
assert_eq!(execute_batch(&p, &[]).expect("valid empty architecture"), p);
let before = p.clone();
let svg = export_svg(&p, diagram).expect("empty architecture is supported");
assert!(svg.contains("viewBox=\"-30 -30 60 60\""));
assert!(svg.ends_with("</svg>"));
assert_eq!(p, before);
}
#[test]
fn typed_function_glyphs_and_unoriented_standard_connections_have_distinct_svg() {
let p = Project::demo();
@@ -1915,7 +2013,7 @@ mod tests {
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
.unwrap()
.id;
let svg = export_svg(&p, diagram);
let svg = export_svg(&p, diagram).expect("supported diagram");
let title = format!("<title>{}</title>", escape(&boundary.name));
let edge_path = svg
.split(&title)
@@ -1989,7 +2087,7 @@ mod tests {
p.place(diagram, id, Position { x: 60., y: 60. });
}
let p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
let svg = export_svg(&p, diagram);
let svg = export_svg(&p, diagram).expect("supported diagram");
let group = svg
.split("<g>")
.find(|g| g.contains(">Grouped work</text>"))
@@ -2080,7 +2178,7 @@ mod tests {
.unwrap()
.id;
let view = project_view(&p, diagram);
let svg = export_svg(&p, diagram);
let svg = export_svg(&p, diagram).expect("supported diagram");
for edge in &view.edges {
assert!(svg.contains(&format!(">{}</text>", escape(&short(p.named(edge.id), 32)))));
}
@@ -2095,7 +2193,7 @@ mod tests {
p.place(d, a, Position { x: 0., y: 0. });
p.place(d, b, Position { x: 400., y: 0. });
p.relate(RelationKind::CapabilityInclude, "Uses", a, b);
let svg = export_svg(&p, d);
let svg = export_svg(&p, d).expect("supported diagram");
assert!(svg.contains("«include»</text>"));
assert!(svg.contains("stroke-dasharray=\"6 4\""));
}
@@ -2183,7 +2281,7 @@ mod tests {
.unwrap();
assert_eq!(moved.position.x, port.position.x + 100.);
assert_eq!(moved.position.y, port.position.y + 70.);
let svg = export_svg(&p, diagram);
let svg = export_svg(&p, diagram).expect("supported diagram");
for port in &view.ports {
assert!(svg.contains(&format!("<title>{}</title>", escape(p.named(port.element)))));
}
@@ -2646,7 +2744,7 @@ mod tests {
});
let view = project_view(&p, diagram);
let geometry = route_diagram(&p, &p.diagrams[&diagram], &view);
let svg = export_svg(&p, diagram);
let svg = export_svg(&p, diagram).expect("supported diagram");
let route = &geometry.edges[&edge];
let path = route
.points
@@ -2706,7 +2804,7 @@ mod tests {
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
assert_eq!(ends.len(), 2);
let svg = export_svg(&p, diagram);
let svg = export_svg(&p, diagram).expect("supported diagram");
assert!(svg.contains("parent [0..1]</text>"));
assert!(svg.contains("children [0..*]</text>"));
assert!(!svg.contains("data-navigation=\"source\""));
@@ -27,7 +27,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
validate_candidate(&p).unwrap();
let initial = p.clone();
let path_key = format!("<path data-edge=\"{edge}\"");
let svg = export_svg(&p, diagram);
let svg = export_svg(&p, diagram).expect("supported diagram");
let edge_path = svg
.split(&path_key)
.nth(1)
@@ -46,7 +46,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
.unwrap()
.view_options
.exchange_labels = ExchangeLabels::Items;
let items_svg = export_svg(&p, diagram);
let items_svg = export_svg(&p, diagram).expect("supported diagram");
assert!(items_svg.contains("Data &amp; status"));
let RelationBody::CommunicationMean { oriented, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
@@ -54,7 +54,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
unreachable!()
};
*oriented = true;
let svg = export_svg(&p, diagram);
let svg = export_svg(&p, diagram).expect("supported diagram");
assert!(
svg.split(&path_key)
.nth(1)
@@ -71,7 +71,11 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
.view_options
.hidden_edges
.push(edge);
assert!(!export_svg(&p, diagram).contains(&path_key));
assert!(
!export_svg(&p, diagram)
.expect("supported diagram")
.contains(&path_key)
);
p.diagrams
.get_mut(&diagram)
.unwrap()
@@ -83,7 +87,11 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
.unwrap()
.view_options
.relation_kinds = RelationKinds::Only(vec![]);
assert!(!export_svg(&p, diagram).contains(&path_key));
assert!(
!export_svg(&p, diagram)
.expect("supported diagram")
.contains(&path_key)
);
assert_eq!(p.diagrams[&diagram].nodes, placements);
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
}
+31
View File
@@ -170,6 +170,13 @@ impl Surface {
{
"State views"
}
Self::Saved(id)
if p.diagrams
.get(&id)
.is_some_and(|d| d.projection.scenario().is_some()) =>
{
"Scenarios"
}
Self::Saved(_) => "Architecture",
Self::Process(_) => "Processes and chains",
Self::Scenario(_) => "Scenarios",
@@ -276,6 +283,18 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) {
return Vec::new();
}
if let Some(diagram) = only_diagram
.and_then(|id| p.diagrams.get(&id))
.filter(|d| d.projection.scenario().is_some())
{
return crate::behavior::scenario::State::default().representation_usages(
p,
crate::behavior::scenario_layout::Representation::Saved {
diagram: diagram.id,
},
subject,
);
}
let index = ViewIndex::new(p);
let mut usages = Vec::new();
for diagram in p
@@ -283,6 +302,18 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
.values()
.filter(|diagram| only_diagram.is_none_or(|id| diagram.id == id))
{
if diagram.projection.scenario().is_some() {
usages.extend(
crate::behavior::scenario::State::default().representation_usages(
p,
crate::behavior::scenario_layout::Representation::Saved {
diagram: diagram.id,
},
subject,
),
);
continue;
}
let view = projection::project_diagram_with_index(p, diagram, &index);
let surface = Surface::Saved(diagram.id);
if subject == diagram.id || diagram.projection.state_machine() == Some(subject) {
+3 -1
View File
@@ -1535,7 +1535,8 @@ fn group_label(path: &str) -> Option<&'static str> {
}
fn placement_allowed(project: &Project, diagram: &Diagram, element: &Element, current: Id) -> bool {
(diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
diagram.projection.scenario().is_none()
&& (diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
&& diagram
.projection
.state_machine()
@@ -2683,6 +2684,7 @@ mod tests {
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Overview,
layout: None,
routes: Vec::new(),
view_options: Default::default(),
nodes: BTreeMap::new(),
+1
View File
@@ -813,6 +813,7 @@ mod tests {
description: String::new(),
layer: Layer::Sa,
projection: ProjectionKind::Overview,
layout: None,
routes: Vec::new(),
view_options: Default::default(),
nodes: Default::default(),
+30 -12
View File
@@ -391,11 +391,24 @@ fn modal_width<'a>(
.into()
}
fn diagram(app: &App) -> View<'_, Message> {
if app
.active_diagram
.and_then(|id| app.project().diagrams.get(&id))
.is_some_and(|d| d.projection.scenario().is_some())
{
return heading(
"Saved Scenario view",
crate::app::SAVED_SCENARIO_UNAVAILABLE,
);
}
let mut diagrams = app
.project()
.diagrams
.values()
.filter(|d| d.layer == app.layer || d.projection == ProjectionKind::Traceability)
.filter(|d| {
d.projection.scenario().is_none()
&& (d.layer == app.layer || d.projection == ProjectionKind::Traceability)
})
.map(|d| Choice {
id: d.id,
label: d.name.clone(),
@@ -865,22 +878,27 @@ fn inspector(app: &App) -> View<'_, Message> {
);
}
}
body = body.push(
row![
button(text("Apply changes").size(12))
.padding([9, 12])
.on_press(Message::Apply)
.width(Fill),
action("Revert", Message::RevertProperties)
]
.spacing(5),
);
let actions = row![
button(text("Apply changes").size(12))
.padding([9, 12])
.on_press(Message::Apply)
.width(Fill),
action("Revert", Message::RevertProperties)
]
.spacing(5);
if let Some(d) = &app.draft {
body = body.push(forms::view(d, p));
}
container(
column![
tabs,
column![
tabs,
container(actions).padding(iced::Padding {
right: 14.,
..Default::default()
})
]
.spacing(12),
scrollable(body.padding(iced::Padding {
right: 14.,
..Default::default()
+36 -1
View File
@@ -352,7 +352,8 @@ impl Transition {
}
fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool {
(diagram.layer == layer || diagram.projection == ProjectionKind::Traceability)
(diagram.projection.is_architecture() || diagram.projection.state_machine().is_some())
&& (diagram.layer == layer || diagram.projection == ProjectionKind::Traceability)
&& (diagram.projection.state_machine().is_none() || !targets.is_empty())
&& targets.iter().all(|id| {
p.elements.get(id).is_some_and(|e| {
@@ -1972,3 +1973,37 @@ mod port_authoring_tests {
}
}
}
#[cfg(test)]
mod saved_scenario_destination_tests {
use super::*;
#[test]
fn empty_transition_results_do_not_make_a_scenario_an_architecture_destination() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Scenario, Layer::Oa, "Scenario", None)
.unwrap();
let mut diagram = Diagram {
id: Id::new_v4(),
alias: "target_view".into(),
name: "Target".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: Default::default(),
routes: vec![],
layout: None,
};
assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa));
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
.unwrap();
diagram.projection = ProjectionKind::StateMachine { machine };
assert!(result_view_compatible(&p, &diagram, &[machine], Layer::Oa));
assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa));
diagram.projection = ProjectionKind::Overview;
assert!(result_view_compatible(&p, &diagram, &[], Layer::Oa));
}
}
@@ -13,6 +13,7 @@ fn view(p: &mut Project, number: u128, projection: ProjectionKind) -> Id {
projection,
view_options: Default::default(),
nodes: Default::default(),
layout: None,
routes: vec![],
},
);
+2
View File
@@ -14,6 +14,8 @@ mod communication_limits;
mod data_contracts;
#[path = "http/port_anchors.rs"]
mod port_anchors;
#[path = "http/scenario_layout.rs"]
mod scenario_layout;
#[path = "http/semantic_transitions.rs"]
mod semantic_transitions;
#[path = "http/source6_views.rs"]
@@ -0,0 +1,344 @@
//! Saved Scenario geometry uses the same atomic model/source HTTP transaction.
use super::*;
use oa_core::{
Diagram, DiagramLayout, MessageKind, OccurrenceBody, OccurrenceEndpoint, ProjectionKind,
ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition, ScenarioViewpoint, SharedBody,
source_project::{ProjectLimits, SourceBundle, compile_bundle, format_bundle},
};
use std::collections::BTreeMap;
struct Fixture {
bundle: SourceBundle,
views: [Diagram; 2],
lane: Id,
messages: [Id; 3],
foreign: Id,
}
fn address(occurrence: Id) -> OccurrenceEndpoint {
OccurrenceEndpoint {
references: vec![],
occurrence,
}
}
fn fixture() -> Fixture {
let mut model = Project::empty();
let owner = model
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
.unwrap();
let other = model
.add_element(Kind::Scenario, Layer::Oa, "Other delivery", None)
.unwrap();
for id in [owner, other] {
let Some(SharedBody::Scenario { viewpoint, .. }) =
model.elements.get_mut(&id).unwrap().shared_mut()
else {
panic!("Scenario body")
};
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
}
let sender = model
.add_element(Kind::Entity, Layer::Oa, "Sender", None)
.unwrap();
let receiver = model
.add_element(Kind::Entity, Layer::Oa, "Receiver", None)
.unwrap();
let lane = model
.add_element(Kind::Lifeline, Layer::Oa, "Sender lane", Some(owner))
.unwrap();
let destination = model
.add_element(Kind::Lifeline, Layer::Oa, "Receiver lane", Some(owner))
.unwrap();
for (id, represented) in [(lane, sender), (destination, receiver)] {
*model.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let exchange = model.relate(
RelationKind::CommunicationMean,
"Delivery channel",
sender,
receiver,
);
let mut occurrences = Vec::new();
for (name, parent, complete) in [
("First delivery", owner, true),
("Repeated delivery", owner, true),
("Unfinished delivery", owner, false),
("Foreign delivery", other, false),
] {
let id = model
.add_element(Kind::Occurrence, Layer::Oa, name, Some(parent))
.unwrap();
*model.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: complete.then_some(exchange),
sender: complete.then_some(lane),
receiver: complete.then_some(destination),
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
occurrences.push(id);
}
*model.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = occurrences[..3].to_vec();
*model.elements.get_mut(&other).unwrap().steps_mut().unwrap() = vec![occurrences[3]];
let views = ["delivery_wide", "delivery_review"].map(|alias| Diagram {
id: Id::new_v4(),
alias: alias.into(),
name: alias.into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Scenario { scenario: owner },
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
layout: None,
});
let mut bundle = format_bundle(&model);
let entry = bundle.files.get_mut(&bundle.entry).unwrap();
*entry = format!(
"// Transport fixture rationale\n{}",
entry.replacen("{\n", "{\n include \"notes.arcadia\";\n", 1)
);
bundle.files.insert(
"notes.arcadia".into(),
"// Unrelated rationale: 航海\r\n".into(),
);
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, model);
assert!(
oa_core::validation::validate(&model)
.iter()
.all(|issue| issue.severity != oa_core::validation::Severity::Error)
);
Fixture {
bundle: compiled.bundle,
views,
lane,
messages: [occurrences[0], occurrences[1], occurrences[2]],
foreign: occurrences[3],
}
}
async fn post(
server: &TestServer,
current: &StoredProject,
etag: &str,
commands: Value,
dry_run: bool,
) -> (StatusCode, Option<String>, Value) {
let response = server
.client
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
.header("if-match", etag)
.json(&json!({"commands": commands, "dry_run": dry_run}))
.send()
.await
.unwrap();
let status = response.status();
let etag = response
.headers()
.get("etag")
.map(|value| value.to_str().unwrap().to_owned());
(status, etag, response.json().await.unwrap())
}
async fn assert_unchanged(server: &TestServer, current: &StoredProject, disk: &Path, bytes: &[u8]) {
assert_eq!(
serde_json::to_value(server.get(current.resource_id).await).unwrap(),
serde_json::to_value(current).unwrap(),
"entire stored envelope, including Project, source and revision"
);
assert_eq!(
std::fs::read(disk).unwrap(),
bytes,
"physical stored envelope must not be rewritten"
);
}
fn assert_source_pair(current: &StoredProject, notes: &str) {
let compiled = compile_bundle(&current.source_project, ProjectLimits::default()).unwrap();
assert_eq!(compiled.bundle, current.source_project);
assert_eq!(compiled.decoded.project, current.model);
assert_eq!(current.source_project.files["notes.arcadia"], notes);
assert!(
current.source_project.files[&current.source_project.entry]
.starts_with("// Transport fixture rationale\n")
);
}
#[tokio::test]
async fn saved_scenario_positions_roundtrip_through_http_without_partial_or_noop_writes() {
let f = fixture();
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let response = server
.client
.post(server.url("/projects/import-source-project"))
.json(&f.bundle)
.send()
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let summary: Value = response.json().await.unwrap();
let id = summary["resource_id"].as_str().unwrap().parse().unwrap();
let original = server.get(id).await;
assert_eq!(original.source_project, f.bundle);
let disk = directory.path().join(format!("{id}.json"));
let original_bytes = std::fs::read(&disk).unwrap();
let [wide, review] = f.views.clone();
// Raw JSON exercises the actual externally-tagged transport fields. Two
// messages deliberately share one engineering exchange but retain distinct IDs.
let commands = json!([
{"CreateDiagram": wide}, {"CreateDiagram": review},
{"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": -120.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[0]}, "send_y": 200.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[1]}, "send_y": 275.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[2]}, "send_y": -35.0}}},
{"SetScenarioLanePosition": {"diagram": review.id, "position": {"lifeline": f.lane, "x": 760.0}}},
{"SetScenarioMessagePosition": {"diagram": review.id, "position": {"address": {"references": [], "occurrence": f.messages[0]}, "send_y": 430.0}}}
]);
// Independent expected Project: populate the specified records directly;
// do not use execute_batch/prepare_command as the success oracle.
let mut expected = original.model.clone();
let mut expected_wide = wide.clone();
expected_wide.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: f.lane,
x: -120.0,
}],
messages: f
.messages
.into_iter()
.zip([200., 275., -35.])
.map(|(id, send_y)| ScenarioMessagePosition {
address: address(id),
send_y,
})
.collect(),
}));
let mut expected_review = review.clone();
expected_review.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
lanes: vec![ScenarioLanePosition {
lifeline: f.lane,
x: 760.0,
}],
messages: vec![ScenarioMessagePosition {
address: address(f.messages[0]),
send_y: 430.0,
}],
}));
expected.diagrams.insert(wide.id, expected_wide);
expected.diagrams.insert(review.id, expected_review.clone());
let (status, etag, preview) =
post(&server, &original, &original.etag(), commands.clone(), true).await;
assert_eq!(status, StatusCode::OK, "{preview}");
assert_eq!(etag, Some(original.etag()));
assert_eq!(preview["revision"], original.revision);
assert_eq!(preview["changed"], true);
assert_eq!(
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
expected
);
let preview_source: SourceBundle =
serde_json::from_value(preview["source_project"].clone()).unwrap();
assert_eq!(
compile_bundle(&preview_source, ProjectLimits::default())
.unwrap()
.decoded
.project,
expected
);
assert_unchanged(&server, &original, &disk, &original_bytes).await;
let (status, etag, committed) =
post(&server, &original, &original.etag(), commands, false).await;
assert_eq!(status, StatusCode::OK, "{committed}");
assert_eq!(committed["changed"], true);
let current = server.get(id).await;
assert_eq!(etag, Some(current.etag()));
assert_eq!(current.revision, original.revision + 1);
assert_eq!(current.model, expected);
assert_eq!(
current.source_project, preview_source,
"preview and commit must agree exactly"
);
assert_source_pair(&current, &f.bundle.files["notes.arcadia"]);
let current_bytes = std::fs::read(&disk).unwrap();
let no_op = json!([
{"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": -120.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[2]}, "send_y": -35.0}}}
]);
for dry_run in [true, false] {
let (status, etag, result) =
post(&server, &current, &current.etag(), no_op.clone(), dry_run).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(etag, Some(current.etag()));
assert_eq!(result["changed"], false);
assert_eq!(result["revision"], current.revision);
assert_unchanged(&server, &current, &disk, &current_bytes).await;
}
// A stale request must fail even when its coordinate value is a no-op.
let (status, _, result) = post(&server, &current, &original.etag(), no_op, false).await;
assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{result}");
assert_eq!(result["code"], "revision_conflict");
assert_unchanged(&server, &current, &disk, &current_bytes).await;
// First command is valid. The second belongs to another Scenario; neither
// a dry-run nor a real transaction may expose/persist the partial candidate.
let invalid = json!([
{"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": 999.0}}},
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.foreign}, "send_y": 400.0}}}
]);
for dry_run in [true, false] {
let (status, _, result) =
post(&server, &current, &current.etag(), invalid.clone(), dry_run).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{result}");
assert_eq!(result["code"], "command_rejected");
assert!(result.get("model").is_none());
assert_unchanged(&server, &current, &disk, &current_bytes).await;
}
let mut removals =
vec![json!({"RemoveScenarioLanePosition": {"diagram": wide.id, "lifeline": f.lane}})];
removals.extend(f.messages.map(|occurrence| json!({"RemoveScenarioMessagePosition": {"diagram": wide.id, "address": {"references": [], "occurrence": occurrence}}})));
let removals = Value::Array(removals);
let (status, _, result) =
post(&server, &current, &current.etag(), removals.clone(), false).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(result["changed"], true);
let removed = server.get(id).await;
assert_eq!(removed.revision, current.revision + 1);
expected.diagrams.get_mut(&wide.id).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
assert_eq!(
removed.model, expected,
"removal changes only the addressed saved view"
);
assert_eq!(removed.model.diagrams[&review.id], expected_review);
assert_source_pair(&removed, &f.bundle.files["notes.arcadia"]);
let removed_bytes = std::fs::read(&disk).unwrap();
let (status, _, result) = post(&server, &removed, &removed.etag(), removals, false).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert_eq!(result["changed"], false);
assert_unchanged(&server, &removed, &disk, &removed_bytes).await;
let exported = server
.client
.get(server.url(&format!("/projects/{id}/source-project")))
.send()
.await
.unwrap();
assert_eq!(exported.status(), StatusCode::OK);
assert_eq!(exported.headers()["etag"].to_str().unwrap(), removed.etag());
assert_eq!(
exported.json::<SourceBundle>().await.unwrap(),
removed.source_project
);
server.stop().await;
let restarted = TestServer::start(directory.path(), None).await;
assert_unchanged(&restarted, &removed, &disk, &removed_bytes).await;
restarted.stop().await;
}
+71
View File
@@ -0,0 +1,71 @@
# Saved Scenario and Text composition
Branch-specific source composition from root `5960563b23b3f28f83343c283b7c156bf248994a`
and Scenario/inspector `14e7e6aa46f59346c7a9a478fc9498fd0b496aaf`.
This is an unverified integration candidate, not a release or completed native gate.
Live integration/release ownership remains with the parent session and Gitea #27.
## Source decisions
The functional Scenario delta is `208268c..14e7e6a` under `crates/`: typed
activations, saved layout and source lifecycle, complete geometry, scoped
navigation and spacing, Details/Properties, arrangement picker and fixed
inspector actions. Applying that delta with three-way content reconciliation
avoids replaying historical work independently integrated by root. Root's
instructions, user specification, records, dependencies, vendor patch and
documentation are retained; old live ledgers are not imported. Attachments,
State bends, latest transition work, private source8 and resize are separate.
The lossless writer retains root's CompleteForEdit comparison authority and
Compact/CompactValues insertion templates. Scenario EditContext is threaded
through the same record/list writer and keys only Diagram.layout.Scenario rows
by their qualified identity. Authored layout none survives unrelated changes;
clearing a real payload removes it and retains comments. No global null default
was added to the comparison representation.
App admission preserves current TextWorkspace/source/allocation/navigation and
rename-scroll envelopes before shared boundary effects. Scenario spacing and
representation/visit checks also run before those effects. Scope input-owner
equality now includes Scenario representation and visit. Current source Tab and
selection behavior are retained, as are passive WorkDrag reveal exceptions.
Root intentionally removed the obsolete panel-focus mode and Canvas button.
This integration retains that always-active canvas policy. Saved Scenario
Details opens the current inspector and dismisses its outline without replacing
the view or camera. Peer tests formerly hiding/restoring panels now exercise
the visible inspector and retained selection/view/camera. F6/Tab logical
keyboard ownership and root's same-batch suppression remain unchanged.
Typed hit roles retain the root activation-priority rules and boundary paint.
The derived sequence helper remains available to root's production deletion
eligibility guard; peer's test-only gate is inappropriate with that caller.
Properties uses the corrected two-child outer column with its Scrollable at
child 1, preserving the Properties/Connections reconciliation fix.
## Verification to perform after compiler lease
Rustfmt parsing and Git whitespace/conflict-marker checks precede the source
commit. No compiler, tests or GUI have run for this candidate at this checkpoint.
New discriminating tests combine ordinary omitted/authored defaults with
Scenario row reorder, alias rename and layout clear, and retain a live saved
spacing gesture across stale Text commands and prior Scenario visit messages.
First compile/list core and desktop tests on the frozen candidate, inspect the
actual inventory, and resolve union-only API/literal failures. Run core
`scenario_layout`, `references::scenario_layout_tests`, default-omission and
source lifecycle checks; desktop Scenario/spacing/Details/navigation,
source_indentation, rename-scroll, source-input and canvas-focus tests; HTTP
Scenario layout transaction/export/restart checks. Then full workspace tests,
strict workspace/all-target lint and matching GUI build. Existing peer totals
are not additive acceptance targets.
Native combined acceptance must include saved view/picker/Details, lower-field
Apply/Revert and Properties/Connections return, spacing plus cancellation,
Text Tab/undo/edit/Apply and return, exact view/visit restoration, save/reopen,
and preserved active keyboard focus. No native or cross-platform claim follows
from source composition. Existing smaller-window and accessibility limits stay
in force until separately exercised.
Later attachment integration must adapt new EdgeRoute literals, notably core
scenario_layout tests. Later transition integration must retain Scenario's
architecture-destination exclusion while adding counterpart search/footer.