Preserve canvas context across property edits and contextual drawing

This commit is contained in:
2026-10-01 22:49:47 -07:00
parent 6520a89dc0
commit c7065db76e
8 changed files with 1205 additions and 23 deletions
@@ -0,0 +1,304 @@
//! Populated presentation survives mixed-layer projection switches.
//! Complements projection_switching.rs without external fixture dependencies.
use oa_core::{
projection::*, routing::route_diagram, source_project::*, view_options::ViewIndex, *,
};
struct Populated {
project: Project,
diagram: Id,
groups: [Id; 2],
children: [[Id; 2]; 2],
edges: [Id; 2],
ports: [Id; 2],
trace: Id,
}
fn xy(x: f32, y: f32) -> Position {
Position { x, y }
}
fn apply(project: &mut Project, command: Command) {
*project = prepare_command(project, &command).unwrap().project;
}
fn fixture(layer: Layer, collapsed: bool) -> Populated {
let mut p = Project::empty();
let oa_group = p
.add_element(Kind::Activity, Layer::Oa, "Operational response", None)
.unwrap();
let oa_send = p
.add_element(Kind::Activity, Layer::Oa, "Request repair", Some(oa_group))
.unwrap();
let oa_receive = p
.add_element(Kind::Activity, Layer::Oa, "Perform repair", Some(oa_group))
.unwrap();
let sa_group = p
.add_element(Kind::Function, Layer::Sa, "System response", None)
.unwrap();
let sa_send = p
.add_element(Kind::Function, Layer::Sa, "Dispatch repair", Some(sa_group))
.unwrap();
let sa_receive = p
.add_element(Kind::Function, Layer::Sa, "Execute repair", Some(sa_group))
.unwrap();
let oa_edge = p.relate(
RelationKind::Interaction,
"Repair request",
oa_send,
oa_receive,
);
let sa_edge = p.relate(
RelationKind::FunctionalExchange,
"Repair dispatch",
sa_send,
sa_receive,
);
let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = p.relations[&sa_edge].body
else {
unreachable!()
};
let trace = Id::new_v4();
let refinement = Trace {
id: trace,
alias: p.fresh_alias("dispatch_refinement", "trace", trace),
name: "Dispatch refines repair request".into(),
description: "Keep this cross-layer trace and its authored route.".into(),
oa: oa_send,
sa: sa_send,
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(oa_send).unwrap(),
sa: p.properties(sa_send).unwrap(),
semantic: None,
}),
};
apply(&mut p, Command::CreateTrace(refinement));
let d = p.ensure_diagram(layer);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
let oa_group_node = p.place(d, oa_group, xy(0., 0.));
let oa_send_node = p.place(d, oa_send, xy(40., 120.));
let oa_receive_node = p.place(d, oa_receive, xy(360., 120.));
let sa_group_node = p.place(d, sa_group, xy(0., 500.));
let sa_send_node = p.place(d, sa_send, xy(40., 620.));
let sa_receive_node = p.place(d, sa_receive, xy(360., 620.));
for (node, port, side, offset) in [
(sa_send_node, source_port, PortAnchorSide::Bottom, 0.25),
(sa_receive_node, target_port, PortAnchorSide::Top, 0.75),
] {
apply(
&mut p,
Command::SetPortAnchor {
diagram: d,
node,
anchor: PortAnchor { port, side, offset },
},
);
}
for (edge, y, port_backed) in [
(oa_edge, 260., false),
(sa_edge, 860., true),
(trace, 420., false),
] {
apply(
&mut p,
Command::SetEdgeRoute {
diagram: d,
route: EdgeRoute {
edge,
source_attachment: (!port_backed).then_some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.3,
}),
target_attachment: (!port_backed).then_some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.7,
}),
waypoints: vec![xy(680., y), xy(740., y)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: xy(13., -27.),
}),
},
},
);
}
for node in [oa_group_node, sa_group_node] {
let mut placement = p.diagrams[&d].nodes[&node].clone();
placement.collapsed = collapsed;
placement.size = Size {
width: 310.,
height: 170.,
};
apply(
&mut p,
Command::EditNode {
diagram: d,
original: node,
node: placement,
},
);
}
validate_candidate(&p).unwrap();
Populated {
project: p,
diagram: d,
groups: [oa_group_node, sa_group_node],
children: [
[oa_send_node, oa_receive_node],
[sa_send_node, sa_receive_node],
],
edges: [oa_edge, sa_edge],
ports: [source_port, target_port],
trace,
}
}
fn assert_round_trips(p: &Project) {
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
assert_eq!(
persistence::decode(&serde_json::to_vec(p).unwrap()).unwrap(),
*p
);
assert_eq!(
compile_bundle(&format_bundle(p), ProjectLimits::default())
.unwrap()
.decoded
.project,
*p
);
}
fn assert_layer_only(p: &Project, d: Id, view: &ProjectedView) {
let layer = p.diagrams[&d].layer;
for node in &view.nodes {
assert_eq!(p.elements[&node.element].layer(), layer);
if let Some(parent) = node.visual_parent {
let parent = view.nodes.iter().find(|n| n.id == parent).unwrap();
assert_eq!(p.elements[&parent.element].layer(), layer);
}
}
for port in &view.ports {
assert_eq!(p.elements[&port.element].layer(), layer);
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
assert_eq!(p.elements[&owner.element].layer(), layer);
}
for edge in &view.edges {
assert!(
!edge.trace,
"cross-layer trace leaked into ordinary projection"
);
assert_eq!(p.layer_of(edge.id), Some(layer));
for endpoint in [edge.source, edge.target] {
let owner = view.nodes.iter().find(|n| n.id == endpoint).unwrap();
assert_eq!(p.elements[&owner.element].layer(), layer);
}
for port in [edge.source_port, edge.target_port].into_iter().flatten() {
assert_eq!(p.elements[&port].layer(), layer);
}
}
}
#[test]
fn populated_mixed_projection_switch_conserves_authorship_and_restores_geometry() {
for layer in [Layer::Oa, Layer::Sa] {
for collapsed in [false, true] {
let f = fixture(layer, collapsed);
let baseline = &f.project;
let d = f.diagram;
let before_view = project_view(baseline, d);
let before_routes = route_diagram(baseline, &baseline.diagrams[&d], &before_view);
// Guard against a vacuous fixture: Traceability deliberately flattens parents.
assert_eq!(before_view.nodes.len(), 6);
assert!(before_view.nodes.iter().all(|n| n.visual_parent.is_none()));
for id in [f.edges[0], f.edges[1], f.trace] {
assert!(before_view.edges.iter().any(|e| e.id == id));
let route = baseline.diagrams[&d]
.routes
.iter()
.find(|r| r.edge == id)
.unwrap();
assert_eq!(route.waypoints.len(), 2);
assert!(route.label.is_some());
}
for port in f.ports {
assert!(before_view.ports.iter().any(|p| p.element == port));
}
assert_round_trips(baseline);
for projection in [
ProjectionKind::Overview,
ProjectionKind::Decomposition,
ProjectionKind::Allocation,
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
] {
let mut expected = baseline.clone();
expected.diagrams.get_mut(&d).unwrap().projection = projection;
let mut session = Session::new(baseline.clone());
session
.execute(Command::EditDiagram(d, expected.diagrams[&d].clone()))
.unwrap();
assert_eq!(session.project(), &expected);
assert_round_trips(session.project());
let view = project_view(session.project(), d);
assert_eq!(
view,
project_view_with_index(&expected, d, &ViewIndex::new(&expected))
);
assert_layer_only(session.project(), d, &view);
if projection == ProjectionKind::Overview {
let own = if layer == Layer::Oa { 0 } else { 1 };
assert!(view.nodes.iter().any(|n| n.id == f.groups[own]));
for child in f.children[own] {
if collapsed {
assert!(!view.nodes.iter().any(|n| n.id == child));
} else {
assert_eq!(
view.nodes
.iter()
.find(|n| n.id == child)
.unwrap()
.visual_parent,
Some(f.groups[own])
);
}
}
if !collapsed {
assert!(view.edges.iter().any(|e| e.id == f.edges[own]));
if layer == Layer::Sa {
assert_eq!(view.ports.len(), 2);
}
}
}
session.undo();
assert_eq!(session.project(), baseline);
assert!(
!session.can_undo(),
"projection change must be one model history step"
);
assert_round_trips(session.project());
session.redo();
assert_eq!(session.project(), &expected);
session
.execute(Command::EditDiagram(d, baseline.diagrams[&d].clone()))
.unwrap();
assert_eq!(session.project(), baseline);
let restored = project_view(session.project(), d);
assert_eq!(restored, before_view);
assert_eq!(
route_diagram(
session.project(),
&session.project().diagrams[&d],
&restored
),
before_routes
);
assert_round_trips(session.project());
}
}
}
}
+117 -18
View File
@@ -8,6 +8,8 @@ mod behavior_removal_tests;
mod canvas_selection_tests;
mod checkpoints;
#[cfg(test)]
mod connection_begin_regressions;
#[cfg(test)]
mod diagram_focus_tests;
pub(crate) mod diagram_removal;
#[cfg(test)]
@@ -950,6 +952,7 @@ impl App {
return true;
}
let original = self.selected;
let canvas_surface = self.architecture_surface();
let result = self
.draft
.as_ref()
@@ -1024,6 +1027,19 @@ impl App {
self.selected_node = node;
}
}
// Properties edit the inspected record; they do not request
// navigation away from the architecture view that owns them.
// Follow an explicitly edited diagram identity/layer, never an
// inferred replacement for an unrelated queued canvas click.
if 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
@@ -1226,15 +1242,50 @@ impl App {
}
self.refresh_draft();
}
/// Inspect an already validated canvas target without following its model layer.
/// 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) {
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;
@@ -2751,7 +2802,23 @@ impl App {
e.kind() != Kind::Port && d.projection.includes(e.kind())
})
});
if show_in_mixed_view || menu.request.canvas.is_some() {
// 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();
}
@@ -2957,10 +3024,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))
@@ -5042,8 +5122,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;
};
@@ -5059,10 +5161,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) {
@@ -5076,16 +5181,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 {
@@ -5164,8 +5260,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) => {}
@@ -542,3 +542,363 @@ fn child_creation_navigates_to_a_parent_layer_preferred_view_only_when_needed()
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);
}
}
+18
View File
@@ -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()
+2 -1
View File
@@ -322,7 +322,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.",
"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 Properties keeps that saved architecture view open. 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 or Model explorer menu 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 choosing a record in Model explorer 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.",
+1 -4
View File
@@ -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)
}),
));
}