Merge verified navigation with delivered Help and Save
This commit is contained in:
+208
-42
@@ -4,8 +4,12 @@ pub(crate) mod architecture_authoring;
|
||||
mod architecture_authoring_tests;
|
||||
#[cfg(test)]
|
||||
mod behavior_removal_tests;
|
||||
#[cfg(test)]
|
||||
mod canvas_selection_tests;
|
||||
mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod connection_begin_regressions;
|
||||
#[cfg(test)]
|
||||
mod context_help_tests;
|
||||
#[cfg(test)]
|
||||
mod diagram_focus_tests;
|
||||
@@ -21,6 +25,8 @@ mod hosted;
|
||||
pub(crate) mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
#[cfg(test)]
|
||||
mod placement_relocation_regressions;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
@@ -387,6 +393,7 @@ pub enum Message {
|
||||
Behavior(crate::behavior::Event),
|
||||
Layer(Layer),
|
||||
Select(Id),
|
||||
CanvasSelect(crate::route_edit::Context, Id),
|
||||
EditProperties(Id),
|
||||
SelectNode(Id),
|
||||
DiagramSelection(multiselect::Event),
|
||||
@@ -955,6 +962,16 @@ impl App {
|
||||
return true;
|
||||
}
|
||||
let original = self.selected;
|
||||
let canvas_surface = self.architecture_surface();
|
||||
let placement_owner = self.draft.as_ref().and_then(|draft| match draft.kind {
|
||||
crate::forms::RecordKind::Node(owner) => self
|
||||
.project()
|
||||
.diagrams
|
||||
.get(&owner)
|
||||
.filter(|diagram| diagram.nodes.contains_key(&draft.original))
|
||||
.map(|diagram| diagram.id),
|
||||
_ => None,
|
||||
});
|
||||
let result = self
|
||||
.draft
|
||||
.as_ref()
|
||||
@@ -1009,13 +1026,16 @@ impl App {
|
||||
if let Some(layer) = self.project().layer_of(id) {
|
||||
self.layer = layer;
|
||||
}
|
||||
if let Some((diagram, layer, node)) = self
|
||||
let selected_surface = self
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.id == id || d.nodes.contains_key(&id))
|
||||
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)))
|
||||
{
|
||||
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)));
|
||||
let relocated_placement = placement_owner
|
||||
.zip(selected_surface.and_then(|(diagram, _, node)| node.map(|_| diagram)))
|
||||
.is_some_and(|(before, after)| before != after);
|
||||
if let Some((diagram, layer, node)) = selected_surface {
|
||||
self.layer = layer;
|
||||
if self.page == Page::Behavior
|
||||
&& let Some(machine) =
|
||||
@@ -1029,6 +1049,20 @@ impl App {
|
||||
self.selected_node = node;
|
||||
}
|
||||
}
|
||||
// Ordinary Properties edits retain their saved architecture view.
|
||||
// Follow an explicitly edited diagram identity/layer, never an
|
||||
// inferred replacement for an unrelated queued canvas click.
|
||||
// An accepted placement move explicitly chooses its new view.
|
||||
// Same-owner edits (including a new UUID) still retain inspection.
|
||||
if !relocated_placement && let Some((diagram, _, _)) = canvas_surface {
|
||||
let diagram = if diagram == original { id } else { diagram };
|
||||
if let Some(view) = self.project().diagrams.get(&diagram)
|
||||
&& view.projection.is_architecture()
|
||||
{
|
||||
self.layer = view.layer;
|
||||
self.active_diagram = Some(diagram);
|
||||
}
|
||||
}
|
||||
self.sync();
|
||||
self.status = "Properties applied".into();
|
||||
true
|
||||
@@ -1179,7 +1213,13 @@ impl App {
|
||||
if id != self.selected {
|
||||
self.selected_attachment = None;
|
||||
}
|
||||
if self.selected == id && !process_preview {
|
||||
if self.selected == id
|
||||
&& !process_preview
|
||||
&& self
|
||||
.project()
|
||||
.layer_of(id)
|
||||
.is_none_or(|layer| layer == self.layer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.apply() {
|
||||
@@ -1225,6 +1265,59 @@ impl App {
|
||||
}
|
||||
self.refresh_draft();
|
||||
}
|
||||
/// The saved architecture surface, independent of the Inspector's object.
|
||||
fn architecture_surface(&self) -> Option<(Id, Layer, ProjectionKind)> {
|
||||
if self.page != Page::Diagram {
|
||||
return None;
|
||||
}
|
||||
let diagram = self.project().diagrams.get(&self.active_diagram?)?;
|
||||
diagram.projection.is_architecture().then_some((
|
||||
diagram.id,
|
||||
diagram.layer,
|
||||
diagram.projection,
|
||||
))
|
||||
}
|
||||
fn canvas_target_visible(&self, diagram: Id, id: Id) -> bool {
|
||||
let view = project_view(self.project(), diagram);
|
||||
view.nodes
|
||||
.iter()
|
||||
.any(|node| node.id == id || node.element == id)
|
||||
|| view.ports.iter().any(|port| port.element == id)
|
||||
|| view.edges.iter().any(|edge| edge.id == id)
|
||||
}
|
||||
/// Inspect a contextual target without following its model layer. An edit
|
||||
/// may invalidate a previously visible occurrence; accept the edit but
|
||||
/// refuse that old hit. Invisible Show-in-view targets remain intentional.
|
||||
fn inspect_canvas_target(&mut self, id: Id) -> bool {
|
||||
if self.selected == id {
|
||||
return true;
|
||||
}
|
||||
let surface = self.architecture_surface();
|
||||
let accepted_draft = self.draft_dirty();
|
||||
let was_visible = accepted_draft
|
||||
&& surface.is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id));
|
||||
if !self.project().contains(id) || !self.apply() {
|
||||
return false;
|
||||
}
|
||||
if !self.project().contains(id)
|
||||
|| (was_visible
|
||||
&& (self.architecture_surface() != surface
|
||||
|| !surface
|
||||
.is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id))))
|
||||
{
|
||||
if accepted_draft {
|
||||
self.status =
|
||||
"Properties applied. The view changed; select a visible target again.".into();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.selected = id;
|
||||
self.selected_node = None;
|
||||
self.selected_attachment = None;
|
||||
self.selected_bend = None;
|
||||
self.refresh_draft();
|
||||
true
|
||||
}
|
||||
fn reveal_explorer_selection(&mut self) {
|
||||
if let Some(group) = self.explorer_index.group(self.selected) {
|
||||
self.explorer_groups_collapsed.remove(&group);
|
||||
@@ -1425,6 +1518,11 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::CanvasSelect(captured, _) = &message
|
||||
&& (self.page != Page::Diagram || self.route_context() != Some(*captured))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Selection can change without advancing the captured canvas context.
|
||||
if let Message::Route(
|
||||
crate::route_edit::Event::Commit(captured, changed, ..)
|
||||
@@ -2185,6 +2283,7 @@ impl App {
|
||||
Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
|
||||
Message::Projection(projection) => active || projection.scenario().is_some(),
|
||||
Message::SelectNode(_)
|
||||
| Message::CanvasSelect(..)
|
||||
| Message::PlaceSelected
|
||||
| Message::HideSelected
|
||||
| Message::ToggleCollapse
|
||||
@@ -2785,10 +2884,26 @@ impl App {
|
||||
e.kind() != Kind::Port && d.projection.includes(e.kind())
|
||||
})
|
||||
});
|
||||
if show_in_mixed_view {
|
||||
self.selected = menu.request.object;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
// Draw acts on the visible current view even when opened from
|
||||
// Explorer; explicit Select/Open actions still navigate.
|
||||
let draw_in_current_view = match &*action {
|
||||
Message::Connection(crate::connection_edit::Event::Begin(tool, source)) => {
|
||||
*source == menu.request.object
|
||||
&& self.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::can_begin(
|
||||
self.project(),
|
||||
diagram,
|
||||
*tool,
|
||||
*source,
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if show_in_mixed_view || draw_in_current_view || menu.request.canvas.is_some() {
|
||||
if !self.inspect_canvas_target(menu.request.object) {
|
||||
return Task::none();
|
||||
}
|
||||
} else {
|
||||
self.select(menu.request.object);
|
||||
}
|
||||
@@ -2800,7 +2915,15 @@ impl App {
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
let action = match *action {
|
||||
Message::EditProperties(id)
|
||||
if menu.request.canvas.is_some() && id == menu.request.object =>
|
||||
{
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Properties)
|
||||
}
|
||||
action => action,
|
||||
};
|
||||
return self.update(action);
|
||||
}
|
||||
Message::CreateContextElement(kind, child) => {
|
||||
self.create_kind = kind;
|
||||
@@ -2985,6 +3108,14 @@ impl App {
|
||||
self.status = "Property draft reverted".into();
|
||||
}
|
||||
Message::Select(id) => self.select(id),
|
||||
Message::CanvasSelect(context, id) => {
|
||||
let view = project_view(self.project(), context.diagram);
|
||||
if view.ports.iter().any(|port| port.element == id)
|
||||
|| view.edges.iter().any(|edge| edge.id == id)
|
||||
{
|
||||
self.inspect_canvas_target(id);
|
||||
}
|
||||
}
|
||||
Message::DiagramSelection(event) => self.multiselect_event(event),
|
||||
Message::EditProperties(id) => {
|
||||
self.select(id);
|
||||
@@ -3004,10 +3135,23 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
let original = self.selected;
|
||||
let surface = self.architecture_surface();
|
||||
let accepted_draft = self.draft_dirty();
|
||||
if !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
let id = if id == original { self.selected } else { id };
|
||||
if let Some((diagram, _, _)) = surface
|
||||
&& (self.architecture_surface() != surface
|
||||
|| !self.canvas_target_visible(diagram, id))
|
||||
{
|
||||
if accepted_draft {
|
||||
self.status =
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
.into();
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(d) = self
|
||||
.active_diagram
|
||||
.and_then(|d| self.project().diagrams.get(&d))
|
||||
@@ -3432,7 +3576,7 @@ impl App {
|
||||
self.workflow = Some(crate::workflow::Workflow::relationship_kind(
|
||||
self.project(),
|
||||
self.selected,
|
||||
self.layer,
|
||||
self.project().layer_of(self.selected).unwrap_or(self.layer),
|
||||
kind,
|
||||
incoming,
|
||||
));
|
||||
@@ -4458,13 +4602,18 @@ impl App {
|
||||
self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into();
|
||||
return;
|
||||
}
|
||||
let creation_layer = if child {
|
||||
self.project().layer_of(self.selected).unwrap_or(self.layer)
|
||||
} else {
|
||||
self.layer
|
||||
};
|
||||
let allocated = child
|
||||
&& self.create_kind.is_work()
|
||||
&& self
|
||||
.project()
|
||||
.elements
|
||||
.get(&self.selected)
|
||||
.is_some_and(|e| e.layer() == self.layer && e.kind().is_performer());
|
||||
.is_some_and(|e| e.layer() == creation_layer && e.kind().is_performer());
|
||||
let parent = if child && !allocated {
|
||||
Some(self.selected)
|
||||
} else {
|
||||
@@ -4472,7 +4621,7 @@ impl App {
|
||||
};
|
||||
let Some(e) = self.project().make_element(
|
||||
self.create_kind,
|
||||
self.layer,
|
||||
creation_layer,
|
||||
&format!("New {}", self.create_kind.label().to_lowercase()),
|
||||
parent,
|
||||
) else {
|
||||
@@ -4501,16 +4650,16 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == self.layer && d.projection == preferred)
|
||||
.find(|d| d.layer == creation_layer && d.projection == preferred)
|
||||
.map(|d| d.id);
|
||||
if diagram_target.is_none() {
|
||||
let view = Id::new_v4();
|
||||
commands.push(Command::CreateDiagram(Diagram {
|
||||
id: view,
|
||||
alias: self.project().fresh_alias("engineering_view", "view", view),
|
||||
name: format!("{} {preferred}", self.layer),
|
||||
name: format!("{} {preferred}", creation_layer),
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
layer: creation_layer,
|
||||
projection: preferred,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
@@ -4643,7 +4792,13 @@ impl App {
|
||||
));
|
||||
}
|
||||
if self.execute(Command::Batch(commands)) {
|
||||
let changed_view = self.active_diagram != diagram_target;
|
||||
self.active_diagram = diagram_target;
|
||||
if changed_view
|
||||
&& let Some(view) = diagram_target.and_then(|id| self.project().diagrams.get(&id))
|
||||
{
|
||||
self.layer = view.layer;
|
||||
}
|
||||
self.selected = id;
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
self.refresh_draft();
|
||||
@@ -4657,7 +4812,7 @@ impl App {
|
||||
self.workflow = Some(crate::workflow::Workflow::relationship(
|
||||
self.project(),
|
||||
self.selected,
|
||||
self.layer,
|
||||
self.project().layer_of(self.selected).unwrap_or(self.layer),
|
||||
));
|
||||
}
|
||||
fn add_trace(&mut self) {
|
||||
@@ -5078,8 +5233,30 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
// Admission belongs to the caller: palette choice, contextual source, or
|
||||
// an already admitted session whose complete context is still current.
|
||||
fn arm_connection(
|
||||
&mut self,
|
||||
context: crate::route_edit::Context,
|
||||
tool: crate::connection_tool::Tool,
|
||||
) -> crate::route_edit::Context {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = crate::route_edit::Context {
|
||||
epoch: self.canvas_epoch,
|
||||
..context
|
||||
};
|
||||
self.connection = Some(crate::connection_edit::Session::new(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
context,
|
||||
tool,
|
||||
));
|
||||
self.selected_bend = None;
|
||||
self.status = tool.hint().into();
|
||||
context
|
||||
}
|
||||
fn edit_connection(&mut self, event: crate::connection_edit::Event) {
|
||||
use crate::connection_edit::{Event, Session};
|
||||
use crate::connection_edit::Event;
|
||||
let Some(context) = self.route_context() else {
|
||||
return;
|
||||
};
|
||||
@@ -5095,10 +5272,13 @@ impl App {
|
||||
}
|
||||
match event {
|
||||
Event::Begin(tool, source) => {
|
||||
self.edit_connection(Event::Choose(Some(tool)));
|
||||
if let Some(context) = self.route_context() {
|
||||
self.edit_connection(Event::Endpoint(context, source));
|
||||
if !crate::connection_edit::can_begin(self.project(), context.diagram, tool, source)
|
||||
{
|
||||
self.status = "Choose a visible compatible source in this diagram.".into();
|
||||
return;
|
||||
}
|
||||
let context = self.arm_connection(context, tool);
|
||||
self.edit_connection(Event::Endpoint(context, source));
|
||||
}
|
||||
Event::Choose(Some(tool)) => {
|
||||
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
||||
@@ -5112,16 +5292,7 @@ impl App {
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.route_context().unwrap();
|
||||
self.connection = Some(Session::new(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
context,
|
||||
tool,
|
||||
));
|
||||
self.selected_bend = None;
|
||||
self.status = tool.hint().into();
|
||||
self.arm_connection(context, tool);
|
||||
}
|
||||
Event::Endpoint(captured, id) => {
|
||||
if captured != context {
|
||||
@@ -5182,17 +5353,9 @@ impl App {
|
||||
&project_view(self.project(), context.diagram),
|
||||
id,
|
||||
);
|
||||
if session.tool == crate::connection_tool::Tool::Allocation
|
||||
&& self.project().diagrams[&context.diagram].projection
|
||||
== ProjectionKind::Traceability
|
||||
{
|
||||
// Mixed views can allocate either perspective. Choosing
|
||||
// a visible source must not navigate to its preferred view.
|
||||
self.selected = id;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
} else {
|
||||
self.select(id);
|
||||
if !self.inspect_canvas_target(id) {
|
||||
self.connection = Some(session);
|
||||
return;
|
||||
}
|
||||
self.status = if session.candidates.is_empty() {
|
||||
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
|
||||
@@ -5208,8 +5371,11 @@ impl App {
|
||||
Event::Restart(captured) => {
|
||||
if captured == context
|
||||
&& let Some(session) = &self.connection
|
||||
&& session.context == context
|
||||
{
|
||||
self.edit_connection(Event::Choose(Some(session.tool)));
|
||||
// A contextual mixed-layer tool need not be in this layer's
|
||||
// palette. Its unchanged session context retains admission.
|
||||
self.arm_connection(context, session.tool);
|
||||
}
|
||||
}
|
||||
Event::Choose(None) => {}
|
||||
|
||||
@@ -89,7 +89,10 @@ async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field
|
||||
);
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
Message::Select(_)
|
||||
| Message::CanvasSelect(..)
|
||||
| Message::SelectNode(_)
|
||||
| Message::MoveNode(..)
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selected);
|
||||
|
||||
@@ -237,7 +237,10 @@ fn mounted_dirty_property_press_does_not_select_apply_or_destroy_draft() {
|
||||
));
|
||||
assert!(!events.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
Message::Select(_)
|
||||
| Message::CanvasSelect(..)
|
||||
| Message::SelectNode(_)
|
||||
| Message::MoveNode(..)
|
||||
)));
|
||||
send(&mut f.app, events);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selection);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compact neutral tool palette. Tool state never changes its allocated width.
|
||||
use super::*;
|
||||
use crate::connection_tool::Tool;
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Theme, mouse,
|
||||
widget::{
|
||||
@@ -11,7 +12,7 @@ use iced::{
|
||||
#[derive(Clone, Copy)]
|
||||
enum Icon {
|
||||
Element(Kind),
|
||||
Arrow,
|
||||
Connection(Tool),
|
||||
Existing,
|
||||
Select,
|
||||
Collapse(bool),
|
||||
@@ -76,10 +77,59 @@ impl canvas::Program<Message> for Icon {
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::Arrow => {
|
||||
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
|
||||
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
|
||||
}
|
||||
Self::Connection(connection) => match connection {
|
||||
// The OA and SA work-flow tools share a visual family; they
|
||||
// never coexist in a palette. Keep their small glyph legible.
|
||||
Tool::Interaction | Tool::FunctionalExchange => {
|
||||
for x in [1., 11.] {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(
|
||||
Point::new(x, 5.),
|
||||
Size::new(4., 6.),
|
||||
1.5.into(),
|
||||
),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
f.stroke(&line(&[(5., 8.), (10., 8.)]), stroke);
|
||||
f.stroke(&line(&[(7.5, 5.5), (10., 8.), (7.5, 10.5)]), stroke);
|
||||
}
|
||||
Tool::CommunicationMean => {
|
||||
// Participant endpoints, without implying a fixed direction.
|
||||
for x in [1., 11.] {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(x, 4.), Size::new(4., 8.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
f.stroke(&line(&[(5., 8.), (11., 8.)]), stroke);
|
||||
}
|
||||
Tool::BoundaryExchange => {
|
||||
// Ports on facing participant boundaries. Standard exchanges
|
||||
// are unoriented, so the generic tool has no arrowhead.
|
||||
for x in [2., 14.] {
|
||||
f.stroke(&line(&[(x, 2.), (x, 14.)]), stroke);
|
||||
}
|
||||
for x in [1., 12.] {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(x, 6.), Size::new(3., 4.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
f.stroke(&line(&[(4., 8.), (12., 8.)]), stroke);
|
||||
}
|
||||
Tool::Allocation => {
|
||||
// Work enters a performer: allocation can appear as nesting
|
||||
// rather than a relationship line in allocated-work views.
|
||||
f.stroke(&line(&[(1., 2.), (1., 14.), (15., 14.), (15., 2.)]), stroke);
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(Point::new(5., 9.), Size::new(6., 3.), 1.into()),
|
||||
stroke,
|
||||
);
|
||||
f.stroke(&line(&[(8., 1.), (8., 7.)]), stroke);
|
||||
f.stroke(&line(&[(5., 4.), (8., 7.), (11., 4.)]), stroke);
|
||||
}
|
||||
},
|
||||
Self::Existing => {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
|
||||
@@ -234,7 +284,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
}
|
||||
content = content.push(tool(
|
||||
app,
|
||||
Icon::Arrow,
|
||||
Icon::Connection(*connection),
|
||||
connection.label(),
|
||||
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
|
||||
app.connection
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
//! Canvas inspection retains the displayed mixed-layer surface; navigation follows the model.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::model_menu::{Event as MenuEvent, Request};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
process: Id,
|
||||
placement: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let process = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Operational process", None)
|
||||
.unwrap();
|
||||
let capability = p
|
||||
.add_element(Kind::Capability, Layer::Oa, "Operational capability", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Enables",
|
||||
process,
|
||||
capability,
|
||||
);
|
||||
p.ensure_diagram(Layer::Oa);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let placement = p.place(diagram, process, Position { x: 0., y: 0. });
|
||||
p.place(diagram, capability, Position { x: 500., y: 0. });
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(
|
||||
project_view(app.project(), diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.id == edge)
|
||||
);
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
process,
|
||||
placement,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_surface(f: &Fixture) {
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.layer, Layer::Sa);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_projection_switch_restores_all_authored_content_and_one_step_history() {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Projection(ProjectionKind::Allocation));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(project_view(f.app.project(), f.diagram).nodes.is_empty());
|
||||
assert_eq!(f.app.project().diagrams[&f.diagram].nodes.len(), 2);
|
||||
let switched = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &switched.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &switched.bundle);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Projection(ProjectionKind::Traceability));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert!(
|
||||
project_view(f.app.project(), f.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.id == f.edge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_edge_and_group_inspection_preserve_surface_and_history() {
|
||||
for grouped in [false, true] {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
if grouped {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Placement(f.placement),
|
||||
multiselect::Choice::Replace,
|
||||
)));
|
||||
}
|
||||
let message = if grouped {
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Edge(f.edge),
|
||||
multiselect::Choice::Add,
|
||||
))
|
||||
} else {
|
||||
Message::CanvasSelect(f.app.route_context().unwrap(), f.edge)
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Edge(f.edge))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets().len(),
|
||||
if grouped { 2 } else { 1 }
|
||||
);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke(f: &mut Fixture, object: Id, placement: Option<Id>, action: Message) {
|
||||
let mut request = Request::object(object, iced::Point::ORIGIN);
|
||||
request.canvas = f.app.route_context();
|
||||
request.placement = placement;
|
||||
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
|
||||
let token = f.app.model_menu.as_ref().unwrap().token;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(MenuEvent::Invoke(token, Box::new(action))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_context_properties_and_hide_keep_the_captured_surface() {
|
||||
let mut f = fixture();
|
||||
let edge = f.edge;
|
||||
invoke(&mut f, edge, None, Message::EditProperties(edge));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, edge);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let before = f.app.document.snapshot();
|
||||
let process = f.process;
|
||||
let placement = f.placement;
|
||||
invoke(&mut f, process, Some(placement), Message::HideSelected);
|
||||
assert_surface(&f);
|
||||
assert!(
|
||||
!f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&placement)
|
||||
);
|
||||
assert!(f.app.project().elements.contains_key(&process));
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_explicit_navigation_still_follows_the_target_layer() {
|
||||
let mut f = fixture();
|
||||
let process = f.process;
|
||||
let edge = f.edge;
|
||||
invoke(&mut f, edge, None, Message::Select(process));
|
||||
assert_eq!(f.app.selected, process);
|
||||
assert_eq!(f.app.layer, Layer::Oa);
|
||||
assert_ne!(f.app.active_diagram, Some(f.diagram));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_navigation_to_the_inspected_cross_layer_object_follows_its_layer() {
|
||||
let mut f = fixture();
|
||||
let _ = f.app.update(Message::CanvasSelect(
|
||||
f.app.route_context().unwrap(),
|
||||
f.edge,
|
||||
));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Select(f.edge));
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.layer, Layer::Oa);
|
||||
assert_ne!(f.app.active_diagram, Some(f.diagram));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_invalid_draft_and_stale_packets_preserve_selection() {
|
||||
let mut f = fixture();
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::Field("/id".into(), "-".into()));
|
||||
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert_surface(&f);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Edge(f.edge),
|
||||
multiselect::Choice::Replace,
|
||||
)));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
let _ = f.app.update(Message::RevertProperties);
|
||||
f.app.canvas_epoch += 1;
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_layer_port_inspection_and_connection_start_retain_the_view() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let port = Id::new_v4();
|
||||
let element = make_port(
|
||||
&p,
|
||||
port,
|
||||
owner,
|
||||
"Output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(port, element);
|
||||
p.ensure_diagram(Layer::Sa);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let placement = p.place(diagram, owner, Position { x: 0., y: 0. });
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(
|
||||
project_view(app.project(), diagram)
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == port)
|
||||
);
|
||||
let _ = app.update(Message::CanvasSelect(app.route_context().unwrap(), port));
|
||||
assert_eq!(app.selected, port);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Port { port, placement })
|
||||
);
|
||||
// Reset inspection so the endpoint path cannot pass by selecting the same ID.
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
let context = app.route_context().unwrap();
|
||||
// Mixed canvases can retain a tool from either perspective.
|
||||
app.connection = Some(crate::connection_edit::Session::new(
|
||||
app.project(),
|
||||
&project_view(app.project(), diagram),
|
||||
context,
|
||||
crate::connection_tool::Tool::FunctionalExchange,
|
||||
));
|
||||
let _ = app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(context, port),
|
||||
));
|
||||
assert_eq!(app.selected, port);
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(port));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
fn authoring_fixture(performer: bool) -> (Fixture, Id) {
|
||||
let mut f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let owner = project
|
||||
.add_element(
|
||||
if performer {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::Activity
|
||||
},
|
||||
Layer::Oa,
|
||||
"Operational owner",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational target", None)
|
||||
.unwrap();
|
||||
let placement = project.place(f.diagram, owner, Position { x: 0., y: 400. });
|
||||
project.place(f.diagram, target, Position { x: 500., y: 400. });
|
||||
f.app = App::with_project(project);
|
||||
f.app.page = Page::Diagram;
|
||||
f.app.layer = Layer::Sa;
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.selected = f.diagram;
|
||||
f.app.refresh_draft();
|
||||
f.app.reconcile_multiselect();
|
||||
f.process = owner;
|
||||
f.placement = placement;
|
||||
(f, target)
|
||||
}
|
||||
|
||||
fn assert_source_pair(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(
|
||||
app.document.bundle(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_relationship_actions_keep_operational_source_and_commit_one_step() {
|
||||
for context_action in [false, true] {
|
||||
let (mut f, target) = authoring_fixture(false);
|
||||
let before = f.app.document.snapshot();
|
||||
let owner = f.process;
|
||||
let placement = f.placement;
|
||||
if context_action {
|
||||
invoke(
|
||||
&mut f,
|
||||
owner,
|
||||
Some(placement),
|
||||
Message::AddRelationKind(RelationKind::Interaction, false),
|
||||
);
|
||||
} else {
|
||||
let _ = f.app.update(Message::SelectNode(placement));
|
||||
let _ = f.app.update(Message::AddRelation);
|
||||
}
|
||||
assert_surface(&f);
|
||||
let Some(crate::workflow::Workflow::Relation(relation)) = &f.app.workflow else {
|
||||
panic!("relationship form did not open: {}", f.app.status);
|
||||
};
|
||||
assert_eq!(
|
||||
relation.source.as_ref().map(|source| source.id),
|
||||
Some(owner)
|
||||
);
|
||||
assert_eq!(relation.kind, RelationKind::Interaction);
|
||||
let saved = f
|
||||
.app
|
||||
.workflow
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.recovery_snapshot()
|
||||
.unwrap();
|
||||
assert_eq!(saved["Relation"]["layer"], "Oa");
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Workflow(crate::workflow::Event::Target(Choice {
|
||||
id: target,
|
||||
label: "Operational target".into(),
|
||||
})));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert!(f.app.workflow.is_none(), "{}", f.app.status);
|
||||
let created: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1);
|
||||
assert_eq!(
|
||||
(created[0].kind(), created[0].source(), created[0].target()),
|
||||
(RelationKind::Interaction, owner, target)
|
||||
);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_add_child_command_uses_parent_layer_for_containment_and_allocation() {
|
||||
for performer in [false, true] {
|
||||
let (mut f, _) = authoring_fixture(performer);
|
||||
let before = f.app.document.snapshot();
|
||||
let owner = f.process;
|
||||
let placement = f.placement;
|
||||
invoke(
|
||||
&mut f,
|
||||
owner,
|
||||
Some(placement),
|
||||
Message::CreateContextElement(Kind::Activity, true),
|
||||
);
|
||||
assert_surface(&f);
|
||||
let created: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1, "{}", f.app.status);
|
||||
let child = created[0].id;
|
||||
assert_eq!(created[0].kind(), Kind::Activity);
|
||||
assert_eq!(created[0].layer(), Layer::Oa);
|
||||
assert_eq!(
|
||||
created[0].parent,
|
||||
if performer { None } else { Some(owner) }
|
||||
);
|
||||
let allocations: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
|
||||
.collect();
|
||||
assert_eq!(allocations.len(), usize::from(performer));
|
||||
if performer {
|
||||
assert_eq!(
|
||||
(
|
||||
allocations[0].kind(),
|
||||
allocations[0].source(),
|
||||
allocations[0].target()
|
||||
),
|
||||
(RelationKind::Allocation, owner, child)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|node| node.element == child)
|
||||
);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_root_creation_still_uses_the_active_perspective() {
|
||||
let (mut f, _) = authoring_fixture(false);
|
||||
let _ = f.app.update(Message::SelectNode(f.placement));
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::CreateContextElement(Kind::Function, false));
|
||||
assert_surface(&f);
|
||||
let created: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1, "{}", f.app.status);
|
||||
assert_eq!(created[0].kind(), Kind::Function);
|
||||
assert_eq!(created[0].layer(), Layer::Sa);
|
||||
assert_eq!(created[0].parent, None);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_creation_navigates_to_a_parent_layer_preferred_view_only_when_needed() {
|
||||
for existing_preferred in [false, true] {
|
||||
let (mut f, _) = authoring_fixture(false);
|
||||
let mut project = f.app.project().clone();
|
||||
for diagram in project
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.filter(|d| d.layer == Layer::Oa)
|
||||
{
|
||||
diagram.projection = if existing_preferred {
|
||||
ProjectionKind::Allocation
|
||||
} else {
|
||||
ProjectionKind::Overview
|
||||
};
|
||||
}
|
||||
project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Capabilities;
|
||||
f.app = App::with_project(project);
|
||||
let _ = f.app.update(Message::Select(f.process));
|
||||
let _ = f.app.update(Message::Diagram(Choice {
|
||||
id: f.diagram,
|
||||
label: "System capabilities".into(),
|
||||
}));
|
||||
assert_eq!(f.app.selected, f.process);
|
||||
assert_eq!(f.app.layer, Layer::Sa);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::CreateContextElement(Kind::Activity, true));
|
||||
assert_eq!(f.app.layer, Layer::Oa, "{}", f.app.status);
|
||||
let diagram = f.app.active_diagram.unwrap();
|
||||
assert_ne!(diagram, f.diagram);
|
||||
assert_eq!(f.app.project().diagrams[&diagram].layer, Layer::Oa);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&diagram].projection,
|
||||
ProjectionKind::Allocation
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams.len(),
|
||||
before.decoded.project.diagrams.len() + usize::from(!existing_preferred)
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().elements[&f.app.selected].parent,
|
||||
Some(f.process)
|
||||
);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
// Regression scope: a contextual click may accept a valid property draft, but
|
||||
// must not inherit the draft object's semantic-layer navigation. If acceptance
|
||||
// changes the originating diagram identity or hides the clicked target, retain
|
||||
// that one accepted edit and refuse the old click. Do not infer a new target or
|
||||
// reinterpret an old surface packet on a replacement diagram.
|
||||
fn draft_click_fixture() -> (Fixture, Id) {
|
||||
let mut f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let capability = project
|
||||
.add_element(Kind::Capability, Layer::Oa, "Second capability", None)
|
||||
.unwrap();
|
||||
let other_edge = project.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Also enables",
|
||||
f.process,
|
||||
capability,
|
||||
);
|
||||
project.place(f.diagram, capability, Position { x: 500., y: 300. });
|
||||
f.app = App::with_project(project);
|
||||
f.app.page = Page::Diagram;
|
||||
f.app.layer = Layer::Sa;
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.selected = f.diagram;
|
||||
f.app.refresh_draft();
|
||||
f.app.reconcile_multiselect();
|
||||
assert!(
|
||||
project_view(f.app.project(), f.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == other_edge)
|
||||
);
|
||||
(f, other_edge)
|
||||
}
|
||||
|
||||
fn expected_draft_commit(app: &App, command: Command) -> Document {
|
||||
let mut expected = Document::from_bundle(
|
||||
app.document.bundle().clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
expected.execute(command).unwrap();
|
||||
expected
|
||||
}
|
||||
|
||||
fn assert_accepted_draft(app: &App, expected: &Document, revision: u64) {
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
assert!(!app.draft_dirty());
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
assert_source_pair(app);
|
||||
}
|
||||
|
||||
fn assert_one_draft_history(
|
||||
app: &mut App,
|
||||
before: &oa_core::source_project::CompiledSourceProject,
|
||||
expected: &Document,
|
||||
) {
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"the click must not add a second edit"
|
||||
);
|
||||
assert!(app.document.can_redo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
|
||||
fn pending_edge_name(f: &mut Fixture) {
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
assert_surface(f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/name".into(), "Reviewed enables".into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
assert!(f.app.draft.as_ref().unwrap().command().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_cross_layer_draft_then_canvas_edge_retains_surface_and_one_edit() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
// Capture after drafting, so this exercises successful draft application,
|
||||
// not refusal of a packet made stale by the property edit itself.
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, other_edge);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Edge(other_edge))
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_cross_layer_draft_then_node_click_retains_surface_and_one_edit() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
let _ = f.app.update(Message::SelectNode(f.placement));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.process);
|
||||
assert_eq!(f.app.selected_node, Some(f.placement));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Placement(f.placement))
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_reidentity_draft_commits_but_refuses_the_old_surface_click() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let replacement = Id::new_v4();
|
||||
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert!(!f.app.project().diagrams.contains_key(&context.diagram));
|
||||
assert!(
|
||||
f.app
|
||||
.active_diagram
|
||||
.is_some_and(|id| f.app.project().diagrams.contains_key(&id)),
|
||||
"never restore a removed diagram identity"
|
||||
);
|
||||
assert_ne!(f.app.selected, other_edge, "refuse the old surface click");
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_snapshot()
|
||||
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_draft_commits_but_refuses_the_now_hidden_canvas_target() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
|
||||
diagram.projection = ProjectionKind::Allocation;
|
||||
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
|
||||
let _ = f.app.update(Message::FieldShape(
|
||||
"/projection".into(),
|
||||
serde_json::to_value(ProjectionKind::Allocation).unwrap(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_surface(&f);
|
||||
assert!(
|
||||
!project_view(f.app.project(), f.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == other_edge)
|
||||
);
|
||||
assert_ne!(
|
||||
f.app.selected, other_edge,
|
||||
"do not inspect a no-longer-visible hit"
|
||||
);
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_snapshot()
|
||||
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cross_layer_draft_blocks_contextual_clicks_without_changing_history() {
|
||||
for click in 0..3 {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/id".into(), "not-a-uuid".into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let expected_error = f.app.draft.as_ref().unwrap().command().unwrap_err();
|
||||
let draft =
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let message = match click {
|
||||
0 => Message::CanvasSelect(f.app.route_context().unwrap(), other_edge),
|
||||
1 => Message::SelectNode(f.placement),
|
||||
_ => Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Edge(other_edge),
|
||||
multiselect::Choice::Add,
|
||||
)),
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
if click < 2 {
|
||||
assert_eq!(f.app.status, expected_error);
|
||||
} else {
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Finish or cancel the active edit before changing the diagram selection."
|
||||
);
|
||||
}
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selection.primary()
|
||||
);
|
||||
if click == 0 {
|
||||
// Pre-dispatch stale rejection applies no draft and must not emit
|
||||
// either success feedback or a new validation message.
|
||||
let stale = f.app.route_context().unwrap();
|
||||
f.app.canvas_epoch += 1;
|
||||
f.app.status = "Keep previous feedback".into();
|
||||
let _ = f.app.update(Message::CanvasSelect(stale, other_edge));
|
||||
assert_eq!(f.app.status, "Keep previous feedback");
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_select_after_valid_cross_layer_draft_still_navigates_and_commits_once() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
let _ = f.app.update(Message::Select(other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.selected, other_edge);
|
||||
assert_eq!(f.app.layer, Layer::Oa);
|
||||
assert_ne!(f.app.active_diagram, Some(f.diagram));
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_apply_on_cross_layer_edge_retains_surface_and_one_edit() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
let _ = f.app.update(Message::Apply);
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.status, "Properties applied");
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Edge(f.edge))
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_apply_honors_an_intentional_active_diagram_layer_edit() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
|
||||
diagram.layer = Layer::Oa;
|
||||
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
|
||||
let _ = f.app.update(Message::FieldShape(
|
||||
"/layer".into(),
|
||||
serde_json::to_value(Layer::Oa).unwrap(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let _ = f.app.update(Message::Apply);
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(
|
||||
f.app.layer,
|
||||
Layer::Oa,
|
||||
"honor the edited diagram's own layer"
|
||||
);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, f.diagram);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_apply_follows_the_known_active_diagram_reidentity_once() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let replacement = Id::new_v4();
|
||||
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let _ = f.app.update(Message::Apply);
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.layer, Layer::Sa);
|
||||
// This identity was explicitly authored in the diagram's own draft. No
|
||||
// queued click is being remapped or inferred from semantic similarity.
|
||||
assert_eq!(f.app.active_diagram, Some(replacement));
|
||||
assert_eq!(f.app.selected, replacement);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, replacement);
|
||||
assert!(!f.app.project().diagrams.contains_key(&f.diagram));
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Test-only proposal pinned to peer 6520a89dc042dd433454f6cc63cb23b0e6df7911.
|
||||
// Independent App test module; real popup messages retain menu tokens.
|
||||
use super::*;
|
||||
use crate::model_menu::{Event as MenuEvent, Request};
|
||||
|
||||
struct BeginFixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
surface_layer: Layer,
|
||||
source: Id,
|
||||
target: Id,
|
||||
wrong_layer: Id,
|
||||
hidden: Id,
|
||||
tool: crate::connection_tool::Tool,
|
||||
}
|
||||
|
||||
fn begin_fixture(source_layer: Layer, allocation: bool, mixed: bool) -> BeginFixture {
|
||||
use crate::connection_tool::Tool;
|
||||
let mut p = Project::empty();
|
||||
let (work, performer, exchange) = match source_layer {
|
||||
Layer::Oa => (Kind::Activity, Kind::Entity, Tool::Interaction),
|
||||
Layer::Sa => (Kind::Function, Kind::System, Tool::FunctionalExchange),
|
||||
};
|
||||
let opposite = if source_layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let surface_layer = if mixed { opposite } else { source_layer };
|
||||
let source = p
|
||||
.add_element(
|
||||
if allocation { performer } else { work },
|
||||
source_layer,
|
||||
"Source",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let target = p.add_element(work, source_layer, "Target", None).unwrap();
|
||||
let hidden = p
|
||||
.add_element(work, source_layer, "Hidden target", None)
|
||||
.unwrap();
|
||||
let other_work = if opposite == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let wrong_layer = p
|
||||
.add_element(other_work, opposite, "Wrong layer target", None)
|
||||
.unwrap();
|
||||
p.ensure_diagram(source_layer);
|
||||
let diagram = p.ensure_diagram(surface_layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = if mixed {
|
||||
ProjectionKind::Traceability
|
||||
} else {
|
||||
ProjectionKind::Capabilities
|
||||
};
|
||||
p.place(diagram, source, Position { x: 0., y: 0. });
|
||||
p.place(diagram, target, Position { x: 400., y: 0. });
|
||||
if mixed {
|
||||
p.place(diagram, wrong_layer, Position { x: 800., y: 0. });
|
||||
}
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = surface_layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(!app.document.can_undo());
|
||||
BeginFixture {
|
||||
app,
|
||||
diagram,
|
||||
surface_layer,
|
||||
source,
|
||||
target,
|
||||
wrong_layer,
|
||||
hidden,
|
||||
tool: if allocation {
|
||||
Tool::Allocation
|
||||
} else {
|
||||
exchange
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_surface(f: &BeginFixture) {
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.layer, f.surface_layer);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
}
|
||||
|
||||
fn begin_popup_action(f: &mut BeginFixture) -> Option<Message> {
|
||||
begin_popup_action_from(f, true)
|
||||
}
|
||||
|
||||
fn begin_popup_action_from(f: &mut BeginFixture, canvas: bool) -> Option<Message> {
|
||||
let mut request = Request::object(f.source, iced::Point::ORIGIN);
|
||||
if canvas {
|
||||
request.canvas = f.app.route_context();
|
||||
request.placement = f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == f.source)
|
||||
.map(|node| node.id);
|
||||
}
|
||||
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
|
||||
let popup = crate::model_menu::popup(&f.app).expect("real model popup");
|
||||
let expected = format!("Draw {}", f.tool.label().to_lowercase());
|
||||
for entry in popup.entries {
|
||||
if let crate::context_menu::Entry::Action { label, message, .. } = entry
|
||||
&& label == expected
|
||||
{
|
||||
if let Some(Message::Menu(MenuEvent::Invoke(token, _))) = &message {
|
||||
assert_eq!(*token, popup.token);
|
||||
} else {
|
||||
assert!(message.is_none(), "Draw must use the menu token envelope");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
None // Omission is also a valid unavailable-action policy.
|
||||
}
|
||||
|
||||
fn begin_from_popup(f: &mut BeginFixture) -> Message {
|
||||
begin_from_popup_origin(f, true)
|
||||
}
|
||||
|
||||
fn begin_from_popup_origin(f: &mut BeginFixture, canvas: bool) -> Message {
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let action =
|
||||
begin_popup_action_from(f, canvas).expect("visible semantic source offers enabled Draw");
|
||||
let _ = f.app.update(action.clone());
|
||||
begin_surface(f);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let session = f
|
||||
.app
|
||||
.connection
|
||||
.as_ref()
|
||||
.expect("menu Draw must arm its tool");
|
||||
assert_eq!(session.tool, f.tool);
|
||||
assert_eq!(session.source, Some(f.source));
|
||||
assert_eq!(session.context, f.app.route_context().unwrap());
|
||||
assert!(session.candidates.contains(&f.target));
|
||||
assert!(!session.candidates.contains(&f.wrong_layer));
|
||||
assert!(!session.candidates.contains(&f.hidden));
|
||||
action
|
||||
}
|
||||
|
||||
fn begin_complete_and_check_history(f: &mut BeginFixture) {
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(context, f.target),
|
||||
));
|
||||
begin_surface(f);
|
||||
assert!(f.app.connection.is_none());
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
let relations: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| !before.decoded.project.relations.contains_key(&r.id))
|
||||
.collect();
|
||||
assert_eq!(relations.len(), 1);
|
||||
let expected = match f.tool {
|
||||
crate::connection_tool::Tool::Interaction => RelationKind::Interaction,
|
||||
crate::connection_tool::Tool::FunctionalExchange => RelationKind::FunctionalExchange,
|
||||
crate::connection_tool::Tool::Allocation => RelationKind::Allocation,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(relations[0].kind(), expected);
|
||||
assert_eq!(
|
||||
(relations[0].source(), relations[0].target()),
|
||||
(f.source, f.target)
|
||||
);
|
||||
// Random identities are accepted only for the precise command delta.
|
||||
// Everything else, including traces, metadata and view options, stays exact.
|
||||
let mut expected_project = before.decoded.project.clone();
|
||||
let created = relations[0];
|
||||
assert!(
|
||||
expected_project
|
||||
.relations
|
||||
.insert(created.id, created.clone())
|
||||
.is_none()
|
||||
);
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} = &created.body
|
||||
{
|
||||
assert_eq!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
|
||||
assert!(items.is_empty());
|
||||
assert_ne!(source_port, target_port);
|
||||
let output = &f.app.project().elements[source_port];
|
||||
let input = &f.app.project().elements[target_port];
|
||||
assert_eq!(
|
||||
(output.kind(), output.layer(), output.parent),
|
||||
(Kind::Port, Layer::Sa, Some(f.source))
|
||||
);
|
||||
assert_eq!(
|
||||
(input.kind(), input.layer(), input.parent),
|
||||
(Kind::Port, Layer::Sa, Some(f.target))
|
||||
);
|
||||
assert!(
|
||||
matches!(port_contract(output), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items.is_empty())
|
||||
);
|
||||
assert!(
|
||||
matches!(port_contract(input), Some(PortContract::FunctionInput { incoming_items }) if incoming_items.is_empty())
|
||||
);
|
||||
assert!(!before.decoded.project.elements.contains_key(source_port));
|
||||
assert!(!before.decoded.project.elements.contains_key(target_port));
|
||||
assert_eq!(output.id, *source_port);
|
||||
assert_eq!(input.id, *target_port);
|
||||
expected_project
|
||||
.elements
|
||||
.insert(*source_port, output.clone());
|
||||
expected_project
|
||||
.elements
|
||||
.insert(*target_port, input.clone());
|
||||
assert_eq!(
|
||||
f.app.project().elements.len(),
|
||||
before.decoded.project.elements.len() + 2
|
||||
);
|
||||
} else {
|
||||
assert_ne!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
|
||||
assert_eq!(f.app.project().elements, before.decoded.project.elements);
|
||||
}
|
||||
assert_eq!(f.app.project(), &expected_project);
|
||||
assert_eq!(f.app.project().diagrams, before.decoded.project.diagrams);
|
||||
for (id, element) in &before.decoded.project.elements {
|
||||
assert_eq!(&f.app.project().elements[id], element);
|
||||
}
|
||||
begin_assert_source_pair(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!f.app.document.can_undo(),
|
||||
"creation is a single history step"
|
||||
);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &after.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
begin_assert_source_pair(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_exchange_uses_actual_menu_in_both_mixed_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, false, true);
|
||||
begin_from_popup(&mut f);
|
||||
begin_complete_and_check_history(&mut f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_cancel_restart_and_stale_menu_preserve_session_ownership() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, false, true);
|
||||
let original = f.app.document.snapshot();
|
||||
let stale_menu = begin_from_popup(&mut f);
|
||||
let first = f.app.route_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Connection(crate::connection_edit::Event::Restart(
|
||||
first,
|
||||
)));
|
||||
begin_surface(&f);
|
||||
let restarted = f
|
||||
.app
|
||||
.connection
|
||||
.as_ref()
|
||||
.expect("restart retains contextual tool");
|
||||
assert_eq!(restarted.tool, f.tool);
|
||||
assert_eq!(restarted.source, None);
|
||||
assert!(restarted.candidates.contains(&f.source));
|
||||
let current = restarted.context;
|
||||
assert_ne!(current, first);
|
||||
let _ = f.app.update(stale_menu);
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(first, f.target),
|
||||
));
|
||||
assert_eq!(f.app.connection.as_ref().unwrap().context, current);
|
||||
assert_eq!(f.app.connection.as_ref().unwrap().source, None);
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(current, f.source),
|
||||
));
|
||||
assert_eq!(f.app.connection.as_ref().unwrap().source, Some(f.source));
|
||||
let _ = f.app.update(Message::Escape);
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(current, f.target),
|
||||
));
|
||||
assert!(f.app.connection.is_none());
|
||||
begin_surface(&f);
|
||||
assert_eq!(f.app.project(), &original.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &original.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_allocation_uses_actual_menu_in_both_mixed_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, true, true);
|
||||
begin_from_popup(&mut f);
|
||||
begin_complete_and_check_history(&mut f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_allocation_is_not_enabled_when_projection_hides_work() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, true, false);
|
||||
let view = project_view(f.app.project(), f.diagram);
|
||||
assert!(view.nodes.iter().any(|n| n.element == f.source));
|
||||
assert!(!view.nodes.iter().any(|n| n.element == f.target));
|
||||
assert!(!f.tool.available_in(layer, ProjectionKind::Capabilities));
|
||||
let before = f.app.document.snapshot();
|
||||
assert!(
|
||||
begin_popup_action(&mut f).is_none(),
|
||||
"Draw allocation must be disabled or omitted when work is not projected"
|
||||
);
|
||||
let _ = f.app.update(Message::Menu(MenuEvent::Close));
|
||||
// Defensive handler refusal complements actual-menu enabledness above.
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Connection(crate::connection_edit::Event::Begin(
|
||||
f.tool, f.source,
|
||||
)));
|
||||
assert!(f.app.connection.is_none());
|
||||
begin_surface(&f);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_assert_source_pair(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(
|
||||
app.document.bundle(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_explorer_draw_retains_visible_mixed_diagram_in_both_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, false, true);
|
||||
// A real Explorer Request::object has no canvas/placement envelope,
|
||||
// but its generated Draw action still targets the visible current view.
|
||||
begin_from_popup_origin(&mut f, false);
|
||||
begin_complete_and_check_history(&mut f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Proposed tests against c7065db76ecca3faa733ccb5f09b87fd0a0958be.
|
||||
use super::*;
|
||||
|
||||
struct RelocationFixture {
|
||||
app: App,
|
||||
source: Id,
|
||||
target: Id,
|
||||
node: Id,
|
||||
other_node: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn relocation_fixture(source_layer: Layer, target_layer: Layer) -> RelocationFixture {
|
||||
let mut p = Project::empty();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Observed work", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Other work", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::Interaction, "Observed exchange", work, other);
|
||||
let source = p.ensure_diagram(source_layer);
|
||||
p.diagrams.get_mut(&source).unwrap().projection = ProjectionKind::Traceability;
|
||||
let node = p.place(source, work, Position { x: 20., y: 20. });
|
||||
let other_node = p.place(source, other, Position { x: 500., y: 20. });
|
||||
let mut destination = p.diagrams[&source].clone();
|
||||
destination.id = Id::new_v4();
|
||||
destination.alias = "relocation_destination".into();
|
||||
destination.name = "Relocation destination".into();
|
||||
destination.layer = target_layer;
|
||||
destination.nodes.clear();
|
||||
let target = destination.id;
|
||||
p.diagrams.insert(target, destination);
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = source_layer;
|
||||
app.active_diagram = Some(source);
|
||||
app.selected = node;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(
|
||||
matches!(app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Node(d) if d == source)
|
||||
);
|
||||
assert!(!app.document.can_undo());
|
||||
RelocationFixture {
|
||||
app,
|
||||
source,
|
||||
target,
|
||||
node,
|
||||
other_node,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn relocation_field(app: &mut App, path: &str, value: impl ToString) {
|
||||
let _ = app.update(Message::Field(path.into(), value.to_string()));
|
||||
}
|
||||
|
||||
fn relocation_source_pair(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(
|
||||
app.document.bundle(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
fn relocation_selection(app: &App, diagram: Id, placement: Id) {
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.layer, app.project().diagrams[&diagram].layer);
|
||||
assert_eq!(app.selected, placement);
|
||||
assert_eq!(app.selected_node, Some(placement));
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.original, placement);
|
||||
assert!(matches!(draft.kind, crate::forms::RecordKind::Node(d) if d == diagram));
|
||||
}
|
||||
|
||||
fn relocation_expected(app: &App, command: Command) -> Document {
|
||||
let mut expected = Document::from_bundle(
|
||||
app.document.bundle().clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
expected.execute(command).unwrap();
|
||||
expected
|
||||
}
|
||||
|
||||
fn relocation_history(
|
||||
app: &mut App,
|
||||
before: &oa_core::source_project::CompiledSourceProject,
|
||||
expected: &Document,
|
||||
) {
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(app.document.can_redo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert!(!app.document.can_redo());
|
||||
relocation_source_pair(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_placement_relocation_follows_actual_owner_with_optional_new_identity() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for reidentify in [false, true] {
|
||||
let mut f = relocation_fixture(Layer::Oa, layer);
|
||||
let before = f.app.document.snapshot();
|
||||
let before_project = before.decoded.project.clone();
|
||||
let revision = f.app.document.revision();
|
||||
let id = if reidentify { Id::new_v4() } else { f.node };
|
||||
let mut expected = before_project.clone();
|
||||
let mut placement = expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.node)
|
||||
.unwrap();
|
||||
placement.id = id;
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.target)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(id, placement);
|
||||
let expected_document = relocation_expected(
|
||||
&f.app,
|
||||
Command::Batch(vec![
|
||||
Command::EditNode {
|
||||
diagram: f.source,
|
||||
original: f.node,
|
||||
node: expected.diagrams[&f.target].nodes[&id].clone(),
|
||||
},
|
||||
Command::RelocateNode {
|
||||
node: id,
|
||||
diagram: f.target,
|
||||
},
|
||||
]),
|
||||
);
|
||||
assert_eq!(expected_document.project(), &expected);
|
||||
relocation_field(&mut f.app, "/diagram", f.target);
|
||||
if reidentify {
|
||||
relocation_field(&mut f.app, "/id", id);
|
||||
}
|
||||
assert!(f.app.apply(), "{}", f.app.status);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
relocation_selection(&f.app, f.target, id);
|
||||
relocation_source_pair(&f.app);
|
||||
assert_eq!(f.app.document.bundle(), expected_document.bundle());
|
||||
relocation_history(&mut f.app, &before, &expected_document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_owner_placement_position_alias_and_identity_keep_mixed_surface() {
|
||||
for different_current_view in [false, true] {
|
||||
for field in ["/position/x", "/alias", "/id"] {
|
||||
let mut f = relocation_fixture(Layer::Sa, Layer::Oa);
|
||||
// Ordinary Select plus the diagram picker can retain an independent
|
||||
// placement Inspector while showing a different saved view. This is
|
||||
// an App-handler workflow, not a claim of native menu execution.
|
||||
let _ = f.app.update(Message::Select(f.app.project().id));
|
||||
let _ = f.app.update(Message::Select(f.node));
|
||||
let current = if different_current_view {
|
||||
f.target
|
||||
} else {
|
||||
f.source
|
||||
};
|
||||
if different_current_view {
|
||||
let _ = f.app.update(Message::Diagram(Choice {
|
||||
id: current,
|
||||
label: "Other current view".into(),
|
||||
}));
|
||||
}
|
||||
assert_eq!(f.app.selected, f.node);
|
||||
assert_eq!(f.app.active_diagram, Some(current));
|
||||
assert!(
|
||||
matches!(f.app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Node(d) if d == f.source)
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let mut placement = expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.node)
|
||||
.unwrap();
|
||||
let value = match field {
|
||||
"/position/x" => {
|
||||
placement.position.x = 123.;
|
||||
"123".to_owned()
|
||||
}
|
||||
"/alias" => {
|
||||
placement.alias = "renamed_placement".into();
|
||||
placement.alias.clone()
|
||||
}
|
||||
_ => {
|
||||
placement.id = Id::new_v4();
|
||||
placement.id.to_string()
|
||||
}
|
||||
};
|
||||
let id = placement.id;
|
||||
let expected_document = relocation_expected(
|
||||
&f.app,
|
||||
Command::EditNode {
|
||||
diagram: f.source,
|
||||
original: f.node,
|
||||
node: placement.clone(),
|
||||
},
|
||||
);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(id, placement);
|
||||
assert_eq!(expected_document.project(), &expected);
|
||||
relocation_field(&mut f.app, field, value);
|
||||
assert!(f.app.apply(), "{}", f.app.status);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), expected_document.bundle());
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.active_diagram, Some(current));
|
||||
assert_eq!(f.app.layer, f.app.project().diagrams[¤t].layer);
|
||||
assert_eq!(f.app.selected, id);
|
||||
assert_eq!(
|
||||
f.app.selected_node,
|
||||
if different_current_view {
|
||||
None
|
||||
} else {
|
||||
Some(id)
|
||||
}
|
||||
);
|
||||
let draft = f.app.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.original, id);
|
||||
assert!(matches!(draft.kind, crate::forms::RecordKind::Node(d) if d == f.source));
|
||||
relocation_source_pair(&f.app);
|
||||
relocation_history(&mut f.app, &before, &expected_document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_relocation_refuses_queued_old_canvas_target_with_feedback() {
|
||||
for edge_hit in [false, true] {
|
||||
let mut f = relocation_fixture(Layer::Oa, Layer::Sa);
|
||||
let before = f.app.document.snapshot();
|
||||
let expected_document = relocation_expected(
|
||||
&f.app,
|
||||
Command::Batch(vec![
|
||||
Command::EditNode {
|
||||
diagram: f.source,
|
||||
original: f.node,
|
||||
node: f.app.project().diagrams[&f.source].nodes[&f.node].clone(),
|
||||
},
|
||||
Command::RelocateNode {
|
||||
node: f.node,
|
||||
diagram: f.target,
|
||||
},
|
||||
]),
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let placement = expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.node)
|
||||
.unwrap();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.target)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(f.node, placement);
|
||||
let revision = f.app.document.revision();
|
||||
let captured = f.app.route_context().unwrap();
|
||||
relocation_field(&mut f.app, "/diagram", f.target);
|
||||
let message = if edge_hit {
|
||||
Message::CanvasSelect(captured, f.edge)
|
||||
} else {
|
||||
Message::SelectNode(f.other_node)
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
relocation_selection(&f.app, f.target, f.node);
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
);
|
||||
assert!(
|
||||
f.app.multiselect_snapshot().is_none_or(|snapshot| {
|
||||
!snapshot.targets().iter().any(|target| {
|
||||
matches!(target, crate::diagram_usage::Target::Placement(id) if *id == f.other_node)
|
||||
|| matches!(target, crate::diagram_usage::Target::Edge(id) if *id == f.edge)
|
||||
})
|
||||
}),
|
||||
"the rejected old hit must not enter the destination selection"
|
||||
);
|
||||
assert_eq!(expected_document.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), expected_document.bundle());
|
||||
relocation_source_pair(&f.app);
|
||||
relocation_history(&mut f.app, &before, &expected_document);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_placement_destination_preserves_owner_model_source_and_draft() {
|
||||
let mut f = relocation_fixture(Layer::Oa, Layer::Sa);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
relocation_field(&mut f.app, "/diagram", Id::new_v4());
|
||||
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
assert!(!f.app.apply());
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
relocation_selection(&f.app, f.source, f.node);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,24 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Contextual drawing follows the visible semantic source, not the palette layer.
|
||||
/// The caller separately validates the menu/context lifetime and pending drafts.
|
||||
pub fn can_begin(project: &Project, diagram: Id, tool: Tool, source: Id) -> bool {
|
||||
let Some(diagram) = project.diagrams.get(&diagram) else {
|
||||
return false;
|
||||
};
|
||||
let Some(element) = project.elements.get(&source) else {
|
||||
return false;
|
||||
};
|
||||
diagram.projection.is_architecture()
|
||||
&& (element.layer() == diagram.layer
|
||||
|| diagram.projection == oa_core::ProjectionKind::Traceability)
|
||||
&& Tool::for_layer(element.layer()).contains(&tool)
|
||||
&& tool.can_start(project, source)
|
||||
&& (tool != Tool::Allocation || tool.available_in(element.layer(), diagram.projection))
|
||||
&& visible(&oa_core::projection::project_view(project, diagram.id)).any(|id| id == source)
|
||||
}
|
||||
|
||||
pub fn visible(view: &ProjectedView) -> impl Iterator<Item = Id> + '_ {
|
||||
view.nodes
|
||||
.iter()
|
||||
|
||||
@@ -993,7 +993,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
world,
|
||||
));
|
||||
}
|
||||
return Some(Action::publish(Message::Select(port.element)).and_capture());
|
||||
return Some(
|
||||
Action::publish(Message::CanvasSelect(self.context, port.element))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if let Some(id) = self.hit(view, p) {
|
||||
let position = self
|
||||
@@ -1010,7 +1013,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
.hit_label(&scene.routed, p)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, p))
|
||||
{
|
||||
Some(Action::publish(Message::Select(id)).and_capture())
|
||||
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
|
||||
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
|
||||
if id == self.selected
|
||||
&& self.editable
|
||||
@@ -1026,7 +1029,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
self.begin_route(state, world, Handle::Bend(index), route);
|
||||
Some(Action::capture())
|
||||
} else {
|
||||
Some(Action::publish(Message::Select(id)).and_capture())
|
||||
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
|
||||
}
|
||||
} else {
|
||||
state.pan_start = Some((mouse::Button::Left, p));
|
||||
@@ -2441,7 +2444,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(selected,Some(Message::Select(id)) if id==port.element));
|
||||
assert!(matches!(selected,Some(Message::CanvasSelect(_, id)) if id==port.element));
|
||||
let request = canvas.context_request(
|
||||
&state,
|
||||
click,
|
||||
@@ -3054,7 +3057,7 @@ mod tests {
|
||||
let mut state = State::default();
|
||||
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
|
||||
assert!(
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::CanvasSelect(_, id)) if id==edge)
|
||||
);
|
||||
assert!(
|
||||
state.route_drag.is_none(),
|
||||
@@ -3104,7 +3107,7 @@ mod tests {
|
||||
let center = scene.routed.edges[&edge].label.center;
|
||||
let screen = point(center) + canvas.pan;
|
||||
assert!(
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::Select(id)) if id==edge)
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::CanvasSelect(_, id)) if id==edge)
|
||||
);
|
||||
canvas.selected = edge;
|
||||
pointer(
|
||||
|
||||
@@ -179,7 +179,7 @@ fn coalesced_port_drag_commits_once_through_app_and_source_history() {
|
||||
zoom,
|
||||
);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
|
||||
assert!(matches!(&messages[0],Message::CanvasSelect(_, id)if *id==output));
|
||||
assert!(
|
||||
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
|
||||
);
|
||||
@@ -251,7 +251,7 @@ fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
|
||||
b,
|
||||
);
|
||||
let picked = match &press {
|
||||
Message::Select(id) => Some(*id),
|
||||
Message::CanvasSelect(_, id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let release_debug = format!("{release:?}");
|
||||
@@ -300,7 +300,7 @@ fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
|
||||
.filter_map(|e| event(&c, &mut state, e, b))
|
||||
.collect();
|
||||
assert!(
|
||||
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
|
||||
matches!(messages.first(),Some(Message::CanvasSelect(_, id)) if *id==output),
|
||||
"Stationary source was lost between batches: {messages:?}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -165,7 +165,7 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(press, Some(Message::Select(id)) if id == port.element));
|
||||
assert!(matches!(press, Some(Message::CanvasSelect(_, id)) if id == port.element));
|
||||
assert_eq!(state.port_drag.as_ref().unwrap().anchor.port, port.element);
|
||||
let menu = canvas.context_request(&state, at, bounds);
|
||||
assert_eq!(menu.object, port.element);
|
||||
|
||||
@@ -375,7 +375,7 @@ mod tests {
|
||||
messages.extend(message);
|
||||
}
|
||||
}
|
||||
assert!(matches!(messages.first(), Some(Message::Select(id)) if *id == port));
|
||||
assert!(matches!(messages.first(), Some(Message::CanvasSelect(_, id)) if *id == port));
|
||||
assert!(
|
||||
matches!(messages.last(), Some(Message::PortAnchor(crate::port_edit::Event::Commit(_, _, anchor))) if anchor.port == port && anchor.side == PortAnchorSide::Top)
|
||||
);
|
||||
|
||||
@@ -327,6 +327,8 @@ fn article(topic: Topic) -> Article {
|
||||
"Show a correspondence between OA and SA",
|
||||
&[
|
||||
"Choose OA ↔ SA traceability in View content. Changing this setting does not create counterparts or add their placements, so an existing single-layer diagram can initially look unchanged.",
|
||||
"Switching back to Architecture or another single-layer projection shows only that view's layer. The other layer's placements, routing and saved visibility remain stored; return to OA ↔ SA traceability to show them again, subject to the view's filters. Selecting a displayed card, port or line and opening its Properties keeps the current view and perspective. Applying ordinary Properties edits keeps that saved architecture view open. Changing a placement's Diagram explicitly moves it and follows its destination. Changes you make to the view's own perspective or identity still take effect. If the app asks you to select again, choose a visible target.",
|
||||
"In a mixed traceability view, use Draw from a visible source's canvas menu or the Model explorer sidebar on the Architecture page to connect from that source's OA or SA layer without leaving the view. Choose a highlighted compatible visible target; Escape cancels. The palette offers tools for the view's perspective. Opening an endpoint or selecting a record in the Model explorer sidebar follows that record's perspective. Contextual relationship forms keep the view while opening; applying the reviewed relationship can navigate to its result.",
|
||||
"Use Review layer traces to inspect named Operational and System endpoints and the meaning of their link. This opens the model's layer-trace list with no search restriction; traces between relationships are reviewed here rather than drawn between element cards.",
|
||||
"In the chosen architecture view, use Add existing elements. The chooser offers compatible elements from both layers. Select an existing endpoint and click its position in the canvas; repeat for the other end if needed. This adds a placement, not a new semantic element or trace. For port correspondences, place the ports' owners.",
|
||||
"The view shows correspondence lines alongside ordinary relationships. The shown-line count reflects the current projection and visibility settings, not trace coverage. If a line is hidden, use View visibility and inspect Saved view visibility; placing an endpoint does not remove explicit hides or filters.",
|
||||
|
||||
@@ -557,10 +557,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
&format!("Draw {}", tool.label().to_lowercase()),
|
||||
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
|
||||
app.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::visible(&projection::project_view(
|
||||
p, diagram,
|
||||
))
|
||||
.any(|element| element == id)
|
||||
crate::connection_edit::can_begin(p, diagram, *tool, id)
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user