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
+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());
}
}