Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
301126f609 | ||
|
|
cf37c36be1 | ||
|
|
17f1f89fef | ||
|
|
4fbdf6f385 | ||
|
|
bfc259854f | ||
|
|
b8685416f5 | ||
|
|
26979c1093 | ||
|
|
53d01a3d44 | ||
|
|
3aa80de89c | ||
|
|
79cc9d5551 |
@@ -0,0 +1,27 @@
|
||||
//! Read and validate a saved acceptance fixture without rewriting its source.
|
||||
//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json
|
||||
use oa_core::{persistence, validate_candidate};
|
||||
use std::{fs::OpenOptions, io::Write, path::Path};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() != 2 {
|
||||
return Err("Expected input source/JSON and a new output JSON path".into());
|
||||
}
|
||||
let project = persistence::load(Path::new(&args[0]))?;
|
||||
validate_candidate(&project)?;
|
||||
let bytes = serde_json::to_vec_pretty(&project)?;
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&args[1])?;
|
||||
output.write_all(&bytes)?;
|
||||
output.write_all(b"\n")?;
|
||||
output.sync_all()?;
|
||||
println!(
|
||||
"Validated {} objects and {} views; saved a complete Project snapshot",
|
||||
project.identities().len(),
|
||||
project.diagrams.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Read-only saved-State geometry evidence for a predeclared native bend trial.
|
||||
//!
|
||||
//! Usage:
|
||||
//! state_bend_routing_snapshot INPUT.arcadia NEW_OUTPUT.json
|
||||
//!
|
||||
//! Loads and validates the complete Project, then independently projects/routes
|
||||
//! every saved State view through core APIs. The complete Project permits exact
|
||||
//! semantic and presentation comparison; derived geometry permits declaring a
|
||||
//! segment and insertion position before native actions. No desktop command,
|
||||
//! planner, gesture, or pin insertion helper is called. Input is never written;
|
||||
//! output uses create_new and fails rather than overwriting an existing file.
|
||||
//!
|
||||
//! Limits: this shares production core routing, so it is not an independent
|
||||
//! mathematical test of that router, nor evidence of UI picking/gesture success.
|
||||
//! It does not generate expected mutations or adapt an oracle to observed edits.
|
||||
//! The acceptance recipe must declare the intended segment/index/point and exact
|
||||
//! Project mutation before UI execution, and bind this executable/source/input
|
||||
//! and output hashes externally. Warning strings are diagnostic Debug output,
|
||||
//! not a stable interchange schema. Missing/hidden/internal transitions have no
|
||||
//! routed edge; their semantic records remain in the complete Project snapshot.
|
||||
|
||||
use oa_core::{persistence, projection, routing, validate_candidate};
|
||||
use serde_json::json;
|
||||
use std::{fs::OpenOptions, io::Write, path::Path};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() != 2 {
|
||||
return Err("Expected input source/JSON and a new output JSON path".into());
|
||||
}
|
||||
let project = persistence::load(Path::new(&args[0]))?;
|
||||
validate_candidate(&project)?;
|
||||
let mut views = Vec::new();
|
||||
for (diagram_id, diagram) in &project.diagrams {
|
||||
let Some(machine) = diagram.projection.state_machine() else {
|
||||
continue;
|
||||
};
|
||||
let projected = projection::project_diagram(&project, diagram);
|
||||
let routed = routing::route_diagram(&project, diagram, &projected);
|
||||
let edges: Vec<_> = routed
|
||||
.edges
|
||||
.iter()
|
||||
.map(|(edge_id, edge)| {
|
||||
json!({
|
||||
"edge": edge_id,
|
||||
"id": edge.id,
|
||||
"points": edge.points,
|
||||
"pins": edge.pins,
|
||||
"pin_fractions": edge.pin_fractions,
|
||||
"custom_suspended": edge.custom_suspended,
|
||||
"warnings": edge.warnings.iter().map(|warning| format!("{warning:?}")).collect::<Vec<_>>(),
|
||||
"label": {
|
||||
"text": edge.label.text,
|
||||
"fraction": edge.label.fraction,
|
||||
"anchor": edge.label.anchor,
|
||||
"center": edge.label.center,
|
||||
"bounds": {
|
||||
"position": edge.label.bounds.position,
|
||||
"size": edge.label.bounds.size
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
views.push(json!({
|
||||
"diagram": diagram_id,
|
||||
"machine": machine,
|
||||
"projected": projected,
|
||||
"routed_bounds": {
|
||||
"position": routed.bounds.position,
|
||||
"size": routed.bounds.size
|
||||
},
|
||||
"routed_edges": edges
|
||||
}));
|
||||
}
|
||||
// Preserve the canonical typed Project serialization (including f32
|
||||
// formatting). Serializing it through Value directly promotes f32 to f64.
|
||||
let project_json: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&project)?)?;
|
||||
let snapshot = json!({
|
||||
"schema": "state-bend-routing-snapshot/v1",
|
||||
"project": project_json,
|
||||
"state_views": views
|
||||
});
|
||||
// Finish all reading, validation, routing and serialization before creating
|
||||
// the new evidence file. A failed load cannot leave an apparent snapshot.
|
||||
let mut bytes = serde_json::to_vec_pretty(&snapshot)?;
|
||||
bytes.push(b'\n');
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&args[1])?;
|
||||
output.write_all(&bytes)?;
|
||||
output.sync_all()?;
|
||||
println!(
|
||||
"Validated {} objects and {} saved views; snapshotted {} State views and the complete Project",
|
||||
project.identities().len(),
|
||||
project.diagrams.len(),
|
||||
views.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1502,11 +1502,45 @@ pub struct Diagram {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeRoute {
|
||||
pub edge: Id,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default)]
|
||||
pub waypoints: Vec<Position>,
|
||||
#[serde(default)]
|
||||
pub label: Option<EdgeLabel>,
|
||||
}
|
||||
|
||||
/// Presentation preference on one end of a connection. Real ports take precedence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeAttachment {
|
||||
pub side: PortAnchorSide,
|
||||
pub offset: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdgeEnd {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
|
||||
impl EdgeRoute {
|
||||
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment,
|
||||
EdgeEnd::Target => self.target_attachment,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment = attachment,
|
||||
EdgeEnd::Target => self.target_attachment = attachment,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeLabel {
|
||||
|
||||
+132
-38
@@ -1,7 +1,12 @@
|
||||
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
|
||||
//! callers share this derived geometry for drawing, picking, fitting and export.
|
||||
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
|
||||
use crate::{
|
||||
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
|
||||
RelationBody, RelationKind, Size,
|
||||
};
|
||||
mod attachments;
|
||||
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
|
||||
use std::fmt;
|
||||
@@ -41,6 +46,7 @@ pub struct RoutedEdge {
|
||||
// Derived solver state, never persisted. A preview must inherit the same
|
||||
// remaining work budget as this edge had in stable-ID settled traversal.
|
||||
search_budget_before: usize,
|
||||
attachments: [Option<EdgeAttachment>; 2],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -173,6 +179,7 @@ pub fn association_end_geometry(
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RouteWarning {
|
||||
CoincidentAttachments,
|
||||
PinInsideObstacle { pin: usize, obstacle: Id },
|
||||
NoClearPath,
|
||||
SearchBudgetExhausted,
|
||||
@@ -181,6 +188,9 @@ pub enum RouteWarning {
|
||||
impl fmt::Display for RouteWarning {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::CoincidentAttachments => f.write_str(
|
||||
"The two attachments coincide; move either end to separate the loop tips.",
|
||||
),
|
||||
Self::PinInsideObstacle { pin, .. } => {
|
||||
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
|
||||
}
|
||||
@@ -374,7 +384,14 @@ struct Endpoint {
|
||||
point: Position,
|
||||
stub: Position,
|
||||
}
|
||||
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
|
||||
fn endpoints(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
port: Option<Id>,
|
||||
view: &ProjectedView,
|
||||
attachment: Option<EdgeAttachment>,
|
||||
) -> Vec<Endpoint> {
|
||||
if let Some(port) = port.and_then(|id| {
|
||||
view.ports
|
||||
.iter()
|
||||
@@ -389,26 +406,18 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
|
||||
),
|
||||
}];
|
||||
}
|
||||
let r = Rect::node(node);
|
||||
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
|
||||
vec![
|
||||
Endpoint {
|
||||
point: pos(r.left, mid.y),
|
||||
stub: pos(r.left - PORT_STUB, mid.y),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(r.right, mid.y),
|
||||
stub: pos(r.right + PORT_STUB, mid.y),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(mid.x, r.top),
|
||||
stub: pos(mid.x, r.top - PORT_STUB),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(mid.x, r.bottom),
|
||||
stub: pos(mid.x, r.bottom + PORT_STUB),
|
||||
},
|
||||
if let Some(attachment) = attachment {
|
||||
return vec![attachments::endpoint(project, diagram, node, attachment)];
|
||||
}
|
||||
[
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
|
||||
@@ -507,6 +516,12 @@ fn route_diagram_impl(
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
|
||||
let custom_suspended = promoted;
|
||||
let route = if promoted { None } else { stored };
|
||||
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
|
||||
semantic_port_endpoint(project, edge.id, end)
|
||||
.is_none()
|
||||
.then(|| route.and_then(|r| r.attachment(end)))
|
||||
.flatten()
|
||||
});
|
||||
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
|
||||
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
|
||||
|
||||
@@ -517,7 +532,10 @@ fn route_diagram_impl(
|
||||
});
|
||||
if let Some(cached) = cached {
|
||||
budget = cached.search_budget_before;
|
||||
if cached.pins == pins && cached.custom_suspended == custom_suspended {
|
||||
if cached.pins == pins
|
||||
&& cached.attachments == attachments
|
||||
&& cached.custom_suspended == custom_suspended
|
||||
{
|
||||
// Label-only editing cannot change the connection beneath the
|
||||
// pointer, including a settled fallback with routing warnings.
|
||||
for &point in &cached.points {
|
||||
@@ -543,9 +561,22 @@ fn route_diagram_impl(
|
||||
}
|
||||
let (points, pin_lengths, failure) = route_edge(
|
||||
edge,
|
||||
source,
|
||||
target,
|
||||
view,
|
||||
&endpoints(
|
||||
project,
|
||||
diagram,
|
||||
source,
|
||||
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
|
||||
view,
|
||||
attachments[0],
|
||||
),
|
||||
&endpoints(
|
||||
project,
|
||||
diagram,
|
||||
target,
|
||||
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
|
||||
view,
|
||||
attachments[1],
|
||||
),
|
||||
&pins,
|
||||
&obstacles,
|
||||
&previous,
|
||||
@@ -574,6 +605,7 @@ fn route_diagram_impl(
|
||||
warnings,
|
||||
custom_suspended,
|
||||
search_budget_before,
|
||||
attachments,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -679,6 +711,46 @@ struct RoutingSpace<'a> {
|
||||
xs: Vec<f32>,
|
||||
ys: Vec<f32>,
|
||||
}
|
||||
|
||||
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
|
||||
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
|
||||
let normal = pos(
|
||||
direction(a.stub.x - a.point.x),
|
||||
direction(a.stub.y - a.point.y),
|
||||
);
|
||||
let tangent = pos(-normal.y, normal.x);
|
||||
let candidates: Vec<_> = [36., 72., 144., 288.]
|
||||
.into_iter()
|
||||
.flat_map(|extent| {
|
||||
[-1., 1.].into_iter().map(move |side| {
|
||||
[
|
||||
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
|
||||
pos(
|
||||
a.stub.x + (normal.x + tangent.x * side) * extent,
|
||||
a.stub.y + (normal.y + tangent.y * side) * extent,
|
||||
),
|
||||
pos(
|
||||
a.stub.x + tangent.x * side * extent,
|
||||
a.stub.y + tangent.y * side * extent,
|
||||
),
|
||||
]
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// These are computed loop guards, not authored hard pins. Prefer a clear
|
||||
// orientation/size before asking the bounded router to connect them.
|
||||
candidates
|
||||
.iter()
|
||||
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
|
||||
.or_else(|| {
|
||||
candidates.iter().find(|p| {
|
||||
p.iter()
|
||||
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
|
||||
})
|
||||
})
|
||||
.copied()
|
||||
.unwrap_or(candidates[0])
|
||||
}
|
||||
impl<'a> RoutingSpace<'a> {
|
||||
fn new(obstacles: &'a [Rect]) -> Self {
|
||||
Self {
|
||||
@@ -692,24 +764,41 @@ impl<'a> RoutingSpace<'a> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn route_edge(
|
||||
edge: &ProjectedEdge,
|
||||
source: &ProjectedNode,
|
||||
target: &ProjectedNode,
|
||||
view: &ProjectedView,
|
||||
source_ends: &[Endpoint],
|
||||
target_ends: &[Endpoint],
|
||||
pins: &[Position],
|
||||
obstacles: &[Rect],
|
||||
previous: &[Segment],
|
||||
budget: &mut usize,
|
||||
) -> EdgePath {
|
||||
// Coincident authored tips are valid presentation, including on self loops.
|
||||
// Preserve them and add an external detour; never index an empty candidate
|
||||
// list or turn the connection into an out-and-back degenerate stub.
|
||||
let coincident = edge.source == edge.target
|
||||
&& source_ends.len() == 1
|
||||
&& target_ends.len() == 1
|
||||
&& source_ends[0].point == target_ends[0].point
|
||||
&& source_ends[0].stub == target_ends[0].stub;
|
||||
let authored_pin_count = pins.len();
|
||||
let mut loop_pins = Vec::new();
|
||||
if coincident {
|
||||
let a = source_ends[0];
|
||||
loop_pins.extend_from_slice(pins);
|
||||
loop_pins.extend(coincident_detour(a, obstacles));
|
||||
}
|
||||
let pins = if coincident { &loop_pins[..] } else { pins };
|
||||
let space = RoutingSpace::new(obstacles);
|
||||
let mut candidates = Vec::new();
|
||||
for a in endpoints(source, edge.source_port, view) {
|
||||
for b in endpoints(target, edge.target_port, view) {
|
||||
for &a in source_ends {
|
||||
for &b in target_ends {
|
||||
// A real self-relation must leave and return at distinct anchors.
|
||||
// Reusing one anchor makes the cheapest path a degenerate stub.
|
||||
if edge.source == edge.target
|
||||
&& edge.source_port.is_none()
|
||||
&& edge.target_port.is_none()
|
||||
&& a.point == b.point
|
||||
&& a.stub == b.stub
|
||||
&& !coincident
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -729,10 +818,9 @@ fn route_edge(
|
||||
// Try all side combinations cheaply before spending the bounded grid budget.
|
||||
for search in [false, true] {
|
||||
for &(estimate, a, b) in &candidates {
|
||||
if best
|
||||
.as_ref()
|
||||
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
|
||||
{
|
||||
if best.as_ref().is_some_and(|(cost, _, _)| {
|
||||
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if obstacles
|
||||
@@ -748,7 +836,7 @@ fn route_edge(
|
||||
let mut occupied = previous.to_vec();
|
||||
record_segments(&mut occupied, &points);
|
||||
let mut pin_lengths = Vec::new();
|
||||
let mut distance = PORT_STUB;
|
||||
let mut distance = manhattan(a.point, a.stub);
|
||||
let mut start = a.stub;
|
||||
let mut failed = false;
|
||||
for (index, end) in pins
|
||||
@@ -792,15 +880,20 @@ fn route_edge(
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((_, points, pin_lengths)) = best {
|
||||
return (points, pin_lengths, None);
|
||||
if let Some((_, points, mut pin_lengths)) = best {
|
||||
pin_lengths.truncate(authored_pin_count);
|
||||
return (
|
||||
points,
|
||||
pin_lengths,
|
||||
coincident.then_some(RouteWarning::CoincidentAttachments),
|
||||
);
|
||||
}
|
||||
// Recoverable fallback retains every hard pin, in order, and the actual port
|
||||
// leads. Its warning is deliberately visible to consumers.
|
||||
let (_, a, b) = candidates[0];
|
||||
let mut points = vec![a.point, a.stub];
|
||||
let mut pin_lengths = Vec::new();
|
||||
let mut distance = PORT_STUB;
|
||||
let mut distance = manhattan(a.point, a.stub);
|
||||
let mut start = a.stub;
|
||||
for (index, end) in pins
|
||||
.iter()
|
||||
@@ -833,6 +926,7 @@ fn route_edge(
|
||||
start = end;
|
||||
}
|
||||
points.push(b.point);
|
||||
pin_lengths.truncate(authored_pin_count);
|
||||
(
|
||||
points,
|
||||
pin_lengths,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! One boundary definition shared by persisted routing and attachment gestures.
|
||||
use super::*;
|
||||
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
|
||||
|
||||
/// Semantic authority, independent of whether projection currently exposes a port.
|
||||
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
|
||||
let endpoint = if let Some(relation) = project.relations.get(&edge) {
|
||||
match (&relation.body, end) {
|
||||
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
|
||||
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
|
||||
(_, EdgeEnd::Source) => relation.source(),
|
||||
(_, EdgeEnd::Target) => relation.target(),
|
||||
}
|
||||
} else {
|
||||
let trace = project.traces.get(&edge)?;
|
||||
match end {
|
||||
EdgeEnd::Source => trace.oa,
|
||||
EdgeEnd::Target => trace.sa,
|
||||
}
|
||||
};
|
||||
project
|
||||
.elements
|
||||
.get(&endpoint)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.map(|_| endpoint)
|
||||
}
|
||||
|
||||
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
|
||||
/// measured over the complete projected bounding side, including its corners.
|
||||
/// Terminate's X uses its outermost stroke intersection rather than a contour.
|
||||
pub fn attachment_point(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
attachment: &EdgeAttachment,
|
||||
) -> Position {
|
||||
let vertical = matches!(
|
||||
attachment.side,
|
||||
PortAnchorSide::Left | PortAnchorSide::Right
|
||||
);
|
||||
let (extent, depth) = if vertical {
|
||||
(node.size.height, node.size.width)
|
||||
} else {
|
||||
(node.size.width, node.size.height)
|
||||
};
|
||||
let along = attachment.offset.clamp(0., 1.) * extent;
|
||||
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
|
||||
let kind = diagram
|
||||
.projection
|
||||
.state_machine()
|
||||
.and_then(|_| project.elements.get(&node.element))
|
||||
.map(|e| e.kind());
|
||||
let inset = match kind {
|
||||
Some(Kind::Choice) => depth * 0.5 * t.abs(),
|
||||
Some(Kind::Terminate) => {
|
||||
depth
|
||||
* attachment
|
||||
.offset
|
||||
.clamp(0., 1.)
|
||||
.min(1. - attachment.offset.clamp(0., 1.))
|
||||
}
|
||||
Some(
|
||||
Kind::Initial
|
||||
| Kind::Junction
|
||||
| Kind::Final
|
||||
| Kind::ShallowHistory
|
||||
| Kind::DeepHistory
|
||||
| Kind::EntryPoint
|
||||
| Kind::ExitPoint,
|
||||
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
|
||||
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
|
||||
_ => {
|
||||
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
|
||||
let corner = (radius - along.min(extent - along)).max(0.);
|
||||
radius - (radius * radius - corner * corner).max(0.).sqrt()
|
||||
}
|
||||
};
|
||||
match attachment.side {
|
||||
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
|
||||
PortAnchorSide::Right => pos(
|
||||
node.position.x + node.size.width - inset,
|
||||
node.position.y + along,
|
||||
),
|
||||
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
|
||||
PortAnchorSide::Bottom => pos(
|
||||
node.position.x + along,
|
||||
node.position.y + node.size.height - inset,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose a cardinal bounding side, then map its fraction onto the painted
|
||||
/// contour. Contour distances cannot distinguish the sides of a circle at its
|
||||
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
|
||||
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
|
||||
/// rounds to hundredths: source/API values retain their complete precision.
|
||||
pub fn attachment_from_point(
|
||||
node: &ProjectedNode,
|
||||
cursor: Position,
|
||||
previous: Option<PortAnchorSide>,
|
||||
hysteresis: f32,
|
||||
) -> EdgeAttachment {
|
||||
let candidate = |side| EdgeAttachment {
|
||||
side,
|
||||
offset: projection::port_anchor_offset(node, side, cursor),
|
||||
};
|
||||
// Measure side intent at the box, not from a distant overshoot: Euclidean
|
||||
// distance differences converge outside a small symbol and would otherwise
|
||||
// let hysteresis retain a perpendicular side indefinitely.
|
||||
let boundary_cursor = pos(
|
||||
cursor
|
||||
.x
|
||||
.clamp(node.position.x, node.position.x + node.size.width),
|
||||
cursor
|
||||
.y
|
||||
.clamp(node.position.y, node.position.y + node.size.height),
|
||||
);
|
||||
let distance = |side| {
|
||||
length(
|
||||
projection::port_anchor_position(node, side, candidate(side).offset),
|
||||
boundary_cursor,
|
||||
)
|
||||
};
|
||||
let mut side = [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
|
||||
.unwrap();
|
||||
let hysteresis = hysteresis
|
||||
.max(0.)
|
||||
.min(node.size.width.min(node.size.height) * 0.2);
|
||||
if let Some(old) = previous
|
||||
&& distance(old) <= distance(side) + hysteresis
|
||||
{
|
||||
side = old;
|
||||
}
|
||||
let mut attachment = candidate(side);
|
||||
attachment.offset = (attachment.offset * 100.).round() / 100.;
|
||||
attachment
|
||||
}
|
||||
|
||||
pub(super) fn endpoint(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
attachment: EdgeAttachment,
|
||||
) -> Endpoint {
|
||||
let point = attachment_point(project, diagram, node, &attachment);
|
||||
let stub = match attachment.side {
|
||||
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
|
||||
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
|
||||
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
|
||||
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
|
||||
};
|
||||
Endpoint { point, stub }
|
||||
}
|
||||
@@ -93,6 +93,18 @@ fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
Context::Diagram => {
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
|
||||
route
|
||||
.entry("source_attachment".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
route
|
||||
.entry("target_attachment".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Context::Node => {
|
||||
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
|
||||
}
|
||||
@@ -244,7 +256,12 @@ fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes {
|
||||
if let Some(route) = route.as_object_mut() {
|
||||
omit_defaults(route, &object(json!({"waypoints": [], "label": null})));
|
||||
omit_defaults(
|
||||
route,
|
||||
&object(
|
||||
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +643,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
let mut routed_edges = BTreeSet::new();
|
||||
for (index, route) in d.routes.iter().enumerate() {
|
||||
for attachment in [route.source_attachment, route.target_attachment]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_attachment_offset",
|
||||
"routes",
|
||||
"Connection attachment offset must be finite and between zero and one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if !routed_edges.insert(route.edge) {
|
||||
error(
|
||||
&mut out,
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
use oa_core::{projection::*, routing::*, *};
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
|
||||
EdgeAttachment { side, offset }
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Report", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.place(d, a, pos(50., 50.));
|
||||
p.place(d, b, pos(750., 200.));
|
||||
(p, d, a, b, edge)
|
||||
}
|
||||
fn stored(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
fn routed(p: &Project, d: Id) -> RoutedView {
|
||||
let diagram = &p.diagrams[&d];
|
||||
route_diagram(p, diagram, &project_diagram(p, diagram))
|
||||
}
|
||||
fn near(a: f32, b: f32) {
|
||||
assert!((a - b).abs() < 0.002, "{a} != {b}");
|
||||
}
|
||||
fn orthogonal(points: &[Position]) {
|
||||
assert!(points.len() >= 2);
|
||||
for span in points.windows(2) {
|
||||
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
|
||||
}
|
||||
}
|
||||
fn length(points: &[Position]) -> f32 {
|
||||
points
|
||||
.windows(2)
|
||||
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
|
||||
let (mut p, d, a, _, edge) = fixture();
|
||||
let initial = routed(&p, d);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
|
||||
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
|
||||
route.waypoints = vec![pos(460., 350.)];
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
|
||||
let full = routed(&p, d);
|
||||
assert_eq!(
|
||||
preview, full,
|
||||
"attachment-only changes must invalidate the label-only path cache"
|
||||
);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
assert_eq!(
|
||||
full.edges[&edge].points[0],
|
||||
pos(
|
||||
n.position.x + n.size.width * 0.25,
|
||||
n.position.y + n.size.height
|
||||
)
|
||||
);
|
||||
assert_eq!(full.edges[&edge].pins, route.waypoints);
|
||||
orthogonal(&full.edges[&edge].points);
|
||||
let node = p
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == a)
|
||||
.unwrap();
|
||||
node.position.x += 75.;
|
||||
node.size.width += 100.;
|
||||
node.size.height += 50.;
|
||||
let moved = routed(&p, d);
|
||||
near(
|
||||
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
|
||||
100.,
|
||||
);
|
||||
near(
|
||||
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
|
||||
50.,
|
||||
);
|
||||
assert_eq!(p.diagrams[&d].routes[0], route);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
|
||||
let (mut p, d, _, _, edge) = fixture();
|
||||
let baseline = routed(&p, d);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
|
||||
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
|
||||
assert_eq!(preview, routed(&p, d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, a, pos(0., 0.));
|
||||
p.place(d, b, pos(600., 0.));
|
||||
let diagram = p.diagrams[&d].clone();
|
||||
let view = project_diagram(&p, &diagram);
|
||||
let expected = route_diagram(&p, &diagram, &view);
|
||||
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
|
||||
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
assert_eq!(routed(&p, d), expected);
|
||||
let mut hidden = view.clone();
|
||||
hidden.ports.clear();
|
||||
for e in &mut hidden.edges {
|
||||
e.source_port = None;
|
||||
e.target_port = None;
|
||||
}
|
||||
let absent = route_diagram(&p, &diagram, &hidden);
|
||||
assert_eq!(
|
||||
route_diagram(&p, &p.diagrams[&d], &hidden),
|
||||
absent,
|
||||
"hidden ports must not activate a dormant node preference"
|
||||
);
|
||||
}
|
||||
|
||||
fn state(kind: Kind) -> (Project, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
|
||||
.unwrap();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::State, Layer::Oa, "B", Some(region))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
p.place(d, a, pos(100., 100.));
|
||||
p.place(d, b, pos(600., 100.));
|
||||
(p, d, a, edge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
|
||||
for kind in [
|
||||
Kind::Initial,
|
||||
Kind::Junction,
|
||||
Kind::Final,
|
||||
Kind::Choice,
|
||||
Kind::ShallowHistory,
|
||||
Kind::DeepHistory,
|
||||
Kind::EntryPoint,
|
||||
Kind::ExitPoint,
|
||||
Kind::Terminate,
|
||||
Kind::Fork,
|
||||
Kind::Join,
|
||||
Kind::State,
|
||||
] {
|
||||
let (p, d, a, _) = state(kind);
|
||||
let diagram = &p.diagrams[&d];
|
||||
let view = project_diagram(&p, diagram);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
for offset in [0., 0.2, 0.5, 0.8, 1.] {
|
||||
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
|
||||
let x = (at.x - n.position.x) / n.size.width;
|
||||
let y = (at.y - n.position.y) / n.size.height;
|
||||
match kind {
|
||||
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
|
||||
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
|
||||
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
|
||||
Kind::State => {
|
||||
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
|
||||
if offset == 0. || offset == 1. {
|
||||
assert!(
|
||||
x != 0. && x != 1. || y != 0. && y != 1.,
|
||||
"rounded corner cannot use bounding-box corner"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
|
||||
let (mut p, d, _, edge) = state(Kind::Choice);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
|
||||
route.waypoints = vec![pos(380., 40.)];
|
||||
route.label = Some(EdgeLabel {
|
||||
fraction: 0.3,
|
||||
offset: pos(0., 0.),
|
||||
});
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
orthogonal(&r.points);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let pin = r.pins[0];
|
||||
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
|
||||
near(
|
||||
r.pin_fractions[0],
|
||||
length(&r.points[..=pin_index]) / length(&r.points),
|
||||
);
|
||||
assert_eq!(
|
||||
r.label.anchor,
|
||||
point_along_polyline(&r.points, 0.3).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
|
||||
for kind in [
|
||||
Kind::Initial,
|
||||
Kind::Choice,
|
||||
Kind::Terminate,
|
||||
Kind::Fork,
|
||||
Kind::State,
|
||||
] {
|
||||
let (p, d, a, _) = state(kind);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for overshoot in [0., 20.] {
|
||||
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
|
||||
let chosen =
|
||||
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
|
||||
assert_eq!(
|
||||
chosen,
|
||||
anchor(PortAnchorSide::Top, 0.5),
|
||||
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
|
||||
);
|
||||
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
|
||||
near(painted.x, n.position.x + n.size.width / 2.);
|
||||
near(
|
||||
painted.y,
|
||||
n.position.y
|
||||
+ if kind == Kind::Terminate {
|
||||
n.size.height / 2.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
);
|
||||
}
|
||||
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
|
||||
let stable =
|
||||
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
|
||||
assert_eq!(
|
||||
stable.side,
|
||||
PortAnchorSide::Right,
|
||||
"corner hysteresis stays stable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
|
||||
let (mut p, d, a, edge) = state(Kind::State);
|
||||
if let RelationBody::StateTransition { target, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*target = a;
|
||||
}
|
||||
for pins in [vec![], vec![pos(400., 30.)]] {
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
|
||||
route.target_attachment = route.source_attachment;
|
||||
route.waypoints = pins.clone();
|
||||
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
orthogonal(&r.points);
|
||||
assert_eq!(r.points.first(), r.points.last());
|
||||
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
|
||||
assert_eq!(r.pins, pins);
|
||||
assert_eq!(r.pin_fractions.len(), pins.len());
|
||||
assert!(
|
||||
r.warnings.contains(&RouteWarning::CoincidentAttachments),
|
||||
"{:?}",
|
||||
r.warnings
|
||||
);
|
||||
assert_eq!(routed(&p, d), result, "deterministic fallback");
|
||||
assert_eq!(p.diagrams[&d].routes[0], route);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
|
||||
for blocker_side in [-1., 1.] {
|
||||
let (mut p, d, a, edge) = state(Kind::State);
|
||||
if let RelationBody::StateTransition { target, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*target = a;
|
||||
}
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
|
||||
route.target_attachment = route.source_attachment;
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
let tip = attachment_point(
|
||||
&p,
|
||||
&p.diagrams[&d],
|
||||
source,
|
||||
&anchor(PortAnchorSide::Top, 0.5),
|
||||
);
|
||||
let region = p.elements[&a].parent;
|
||||
let blocker = p
|
||||
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
|
||||
.unwrap();
|
||||
let left = if blocker_side > 0. {
|
||||
tip.x + 30.
|
||||
} else {
|
||||
tip.x - 30. - 224.
|
||||
};
|
||||
p.place(d, blocker, pos(left, tip.y - 144.));
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
|
||||
orthogonal(&r.points);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
|
||||
for segment in r.points.windows(2) {
|
||||
for step in 0..=100 {
|
||||
let t = step as f32 / 100.;
|
||||
let q = pos(
|
||||
segment[0].x + (segment[1].x - segment[0].x) * t,
|
||||
segment[0].y + (segment[1].y - segment[0].y) * t,
|
||||
);
|
||||
assert!(
|
||||
!(q.x > n.position.x - CLEARANCE
|
||||
&& q.x < n.position.x + n.size.width + CLEARANCE
|
||||
&& q.y > n.position.y - CLEARANCE
|
||||
&& q.y < n.position.y + n.size.height + CLEARANCE),
|
||||
"route crosses blocker: {q:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
r.pins.is_empty(),
|
||||
"generated detours must never become authored pins"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use oa_core::{language, projection::*, routing::*, *};
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn authored(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![pos(520., 420.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
offset: pos(17., -23.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn routed(project: &Project, diagram: Id) -> RoutedView {
|
||||
let d = &project.diagrams[&diagram];
|
||||
route_diagram(project, d, &project_diagram(project, d))
|
||||
}
|
||||
|
||||
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
|
||||
let mut p = language::parse(
|
||||
r#"arcadia 7;
|
||||
project lifecycle "Attachment lifecycle" {
|
||||
oa_activity group "Group" { oa_activity first "First" {} }
|
||||
oa_activity second "Second" {}
|
||||
oa_activity replacement "Replacement" {}
|
||||
interaction signal "Signal" { source @first; target @second; }
|
||||
diagram view "View" {}
|
||||
diagram other "Other" {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (d, other, group, first, second, replacement, edge) = (
|
||||
id(&p, "view"),
|
||||
id(&p, "other"),
|
||||
id(&p, "group"),
|
||||
id(&p, "first"),
|
||||
id(&p, "second"),
|
||||
id(&p, "replacement"),
|
||||
id(&p, "signal"),
|
||||
);
|
||||
for diagram in [d, other] {
|
||||
p.place(diagram, group, pos(20., 20.));
|
||||
p.place(diagram, first, pos(100., 130.));
|
||||
p.place(diagram, second, pos(750., 150.));
|
||||
p.place(diagram, replacement, pos(750., 570.));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(authored(edge));
|
||||
}
|
||||
(p, d, other, group, first, replacement, edge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
|
||||
let (mut p, d, other, group, _, _, edge) = fixture();
|
||||
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
|
||||
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
|
||||
let before = p.clone();
|
||||
let expanded = routed(&p, d);
|
||||
let other_geometry = routed(&p, other);
|
||||
let parent = p.diagrams[&d]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == group)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let folded = routed(&p, d);
|
||||
assert!(folded.edges[&edge].custom_suspended);
|
||||
assert_eq!(folded.edges[&edge].pins, []);
|
||||
assert!(
|
||||
folded.edges[&edge]
|
||||
.warnings
|
||||
.contains(&RouteWarning::PromotedEndpoints)
|
||||
);
|
||||
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
|
||||
let mut automatic = p.clone();
|
||||
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
|
||||
assert_eq!(
|
||||
folded,
|
||||
routed(&automatic, d),
|
||||
"attachments, pins and label must all be dormant on promotion"
|
||||
);
|
||||
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
|
||||
assert_eq!(routed(&p, other), other_geometry);
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = false;
|
||||
assert_eq!(p, before);
|
||||
assert_eq!(routed(&p, d), expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
|
||||
let (p, d, other, _, _, replacement, edge) = fixture();
|
||||
let before = routed(&p, d);
|
||||
let mut relation = p.relations[&edge].clone();
|
||||
let RelationBody::Interaction { target, .. } = &mut relation.body else {
|
||||
unreachable!()
|
||||
};
|
||||
*target = replacement;
|
||||
let mut expected = p.clone();
|
||||
expected.relations.insert(edge, relation.clone());
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::EditRelation(edge, relation))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&expected,
|
||||
"reconnect changes only the semantic endpoint; all per-view records remain exact"
|
||||
);
|
||||
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
|
||||
for diagram in [d, other] {
|
||||
let current = session.project();
|
||||
let view = project_diagram(current, ¤t.diagrams[&diagram]);
|
||||
let node = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == replacement)
|
||||
.unwrap();
|
||||
let preference = current.diagrams[&diagram].routes[0]
|
||||
.target_attachment
|
||||
.unwrap();
|
||||
let expected_tip =
|
||||
attachment_point(current, ¤t.diagrams[&diagram], node, &preference);
|
||||
let result = routed(current, diagram);
|
||||
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
|
||||
assert_eq!(
|
||||
result.edges[&edge].pins,
|
||||
p.diagrams[&diagram].routes[0].waypoints
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
routed(session.project(), d).edges[&edge].points.last(),
|
||||
before.edges[&edge].points.last()
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(routed(session.project(), d), before);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
|
||||
let mut p = Project::empty();
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let actor = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let function = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let mut ports = Vec::new();
|
||||
for (owner, name, contract) in [
|
||||
(
|
||||
system,
|
||||
"Output",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
},
|
||||
),
|
||||
(
|
||||
actor,
|
||||
"Input",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In,
|
||||
},
|
||||
),
|
||||
(
|
||||
function,
|
||||
"Function output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
),
|
||||
] {
|
||||
let port = Id::new_v4();
|
||||
p.elements
|
||||
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
|
||||
ports.push(port);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Function allocation",
|
||||
system,
|
||||
function,
|
||||
);
|
||||
let boundary = p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Boundary",
|
||||
ports[0],
|
||||
ports[1],
|
||||
);
|
||||
let allocation = p.relate(
|
||||
RelationKind::PortAllocation,
|
||||
"Port allocation",
|
||||
ports[0],
|
||||
ports[2],
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, system, pos(0., 0.));
|
||||
p.place(d, actor, pos(700., 0.));
|
||||
p.place(d, function, pos(0., 450.));
|
||||
let original = p.clone();
|
||||
let baseline = routed(&p, d);
|
||||
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
|
||||
assert!(baseline.edges.contains_key(&edge));
|
||||
assert_eq!(
|
||||
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
|
||||
Some(ports[0])
|
||||
);
|
||||
assert_eq!(
|
||||
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
|
||||
Some(target)
|
||||
);
|
||||
let mut route = authored(edge);
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
}
|
||||
assert_eq!(routed(&p, d), baseline);
|
||||
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
|
||||
hidden.ports.clear();
|
||||
for edge in &mut hidden.edges {
|
||||
edge.source_port = None;
|
||||
edge.target_port = None;
|
||||
}
|
||||
assert_eq!(
|
||||
route_diagram(&p, &p.diagrams[&d], &hidden),
|
||||
route_diagram(&original, &original.diagrams[&d], &hidden),
|
||||
"filtered direct Port endpoints must not activate their stored preferences"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use oa_core::{projection::*, routing::*, *};
|
||||
|
||||
#[test]
|
||||
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
|
||||
let mut project = Project::empty();
|
||||
let oa = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Send", None)
|
||||
.unwrap();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
|
||||
let id = Id::new_v4();
|
||||
project.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("port_{}", id.simple()),
|
||||
name: "Status port".into(),
|
||||
description: "Existing legacy port retained by Item trace".into(),
|
||||
parent: Some(owner),
|
||||
body: ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
let trace = Trace {
|
||||
id: Id::new_v4(),
|
||||
alias: "status_correspondence".into(),
|
||||
name: "Status correspondence".into(),
|
||||
description: "Preserve this semantic evidence".into(),
|
||||
oa: ports[0],
|
||||
sa: ports[1],
|
||||
kind: TraceKind::Item,
|
||||
baseline: None,
|
||||
};
|
||||
project.traces.insert(trace.id, trace.clone());
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let owners = [
|
||||
project.place(diagram, oa, Position { x: 100., y: 100. }),
|
||||
project.place(diagram, sa, Position { x: 800., y: 300. }),
|
||||
];
|
||||
validate_candidate(&project).unwrap();
|
||||
let semantic_elements = project.elements.clone();
|
||||
let semantic_relations = project.relations.clone();
|
||||
let semantic_traces = project.traces.clone();
|
||||
let view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
|
||||
assert!(edge.trace);
|
||||
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
|
||||
assert_eq!((edge.source_port, edge.target_port), (None, None));
|
||||
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&owners[index])
|
||||
.unwrap()
|
||||
.port_anchors
|
||||
.push(PortAnchor {
|
||||
port: ports[index],
|
||||
side,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
validate_candidate(&project).unwrap();
|
||||
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
|
||||
for index in 0..2 {
|
||||
let marker = moved_view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == ports[index] && port.owner == owners[index])
|
||||
.unwrap();
|
||||
let points = &moved.edges[&trace.id].points;
|
||||
let tip = if index == 0 {
|
||||
points[0]
|
||||
} else {
|
||||
*points.last().unwrap()
|
||||
};
|
||||
assert_eq!(tip, marker.position);
|
||||
let old = &initial.edges[&trace.id].points;
|
||||
assert_ne!(
|
||||
tip,
|
||||
if index == 0 {
|
||||
old[0]
|
||||
} else {
|
||||
*old.last().unwrap()
|
||||
}
|
||||
);
|
||||
}
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: trace.id,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.9,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.1,
|
||||
}),
|
||||
});
|
||||
validate_candidate(&project).unwrap();
|
||||
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
assert_eq!(dormant_view, moved_view);
|
||||
assert_eq!(
|
||||
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
|
||||
moved
|
||||
);
|
||||
assert_eq!(project.elements, semantic_elements);
|
||||
assert_eq!(project.relations, semantic_relations);
|
||||
assert_eq!(project.traces, semantic_traces);
|
||||
assert_eq!(project.traces[&trace.id], trace);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Route the exact native acceptance fixture and intended saved checkpoints.
|
||||
use oa_core::{projection::project_diagram, routing::route_diagram, *};
|
||||
|
||||
const FIXTURE: &str =
|
||||
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn set(
|
||||
project: &mut Project,
|
||||
view: &str,
|
||||
edge: &str,
|
||||
end: EdgeEnd,
|
||||
attachment: Option<(PortAnchorSide, f32)>,
|
||||
) {
|
||||
let view = id(project, view);
|
||||
let edge = id(project, edge);
|
||||
let route = project
|
||||
.diagrams
|
||||
.get_mut(&view)
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == edge)
|
||||
.unwrap();
|
||||
route.set_attachment(
|
||||
end,
|
||||
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
|
||||
);
|
||||
}
|
||||
|
||||
fn clear(project: &Project, checkpoint: &str) {
|
||||
validate_candidate(project).unwrap();
|
||||
for (view, edges) in [
|
||||
("dispatch_view", &["dispatch_request"][..]),
|
||||
("readiness_view", &["activate", "initialize", "finish"][..]),
|
||||
] {
|
||||
let diagram = &project.diagrams[&id(project, view)];
|
||||
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
|
||||
for alias in edges {
|
||||
let edge = &routed.edges[&id(project, alias)];
|
||||
assert!(
|
||||
edge.warnings.is_empty(),
|
||||
"{checkpoint}/{view}/{alias}: {:?}",
|
||||
edge.warnings
|
||||
);
|
||||
let stored = diagram
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == id(project, alias))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
edge.pins, stored.waypoints,
|
||||
"{checkpoint}/{alias}: manual pins retained"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
|
||||
use EdgeEnd::{Source, Target};
|
||||
use PortAnchorSide::{Bottom, Top};
|
||||
let mut project = language::parse(FIXTURE).unwrap();
|
||||
let baseline = project.clone();
|
||||
clear(&project, "B");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Source,
|
||||
Some((Top, 0.25)),
|
||||
);
|
||||
clear(&project, "O1");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Target,
|
||||
Some((Bottom, 0.75)),
|
||||
);
|
||||
clear(&project, "O2");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Source,
|
||||
None,
|
||||
);
|
||||
clear(&project, "O3");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"activate",
|
||||
Source,
|
||||
Some((Top, 0.25)),
|
||||
);
|
||||
clear(&project, "S1");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"activate",
|
||||
Target,
|
||||
Some((Bottom, 0.75)),
|
||||
);
|
||||
clear(&project, "S2");
|
||||
set(&mut project, "readiness_view", "activate", Target, None);
|
||||
clear(&project, "S3");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"initialize",
|
||||
Source,
|
||||
Some((Top, 0.5)),
|
||||
);
|
||||
clear(&project, "S4");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"finish",
|
||||
Source,
|
||||
Some((Top, 0.5)),
|
||||
);
|
||||
clear(&project, "S5");
|
||||
assert_eq!(project.elements, baseline.elements);
|
||||
assert_eq!(project.relations, baseline.relations);
|
||||
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
|
||||
let view = id(&project, alias);
|
||||
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
|
||||
}
|
||||
for alias in ["dispatch_view", "readiness_view"] {
|
||||
let view = id(&project, alias);
|
||||
assert_eq!(
|
||||
project.diagrams[&view].nodes,
|
||||
baseline.diagrams[&view].nodes
|
||||
);
|
||||
for route in &project.diagrams[&view].routes {
|
||||
let original = baseline.diagrams[&view]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == route.edge)
|
||||
.unwrap();
|
||||
assert_eq!(route.waypoints, original.waypoints);
|
||||
assert_eq!(route.label, original.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
use oa_core::{language, persistence, source_project::*, *};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const SOURCE: &str = r#"arcadia 7;
|
||||
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
|
||||
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
|
||||
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
|
||||
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
|
||||
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
|
||||
label { fraction 0.5; offset { x 3; y -7; }; };
|
||||
source_attachment { side Left; offset 0.25; };
|
||||
target_attachment { side Bottom; offset 1; };
|
||||
}];
|
||||
}
|
||||
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
|
||||
}
|
||||
"#;
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn original() -> Project {
|
||||
language::parse(SOURCE).unwrap()
|
||||
}
|
||||
|
||||
fn set(diagram: Id, route: EdgeRoute) -> Command {
|
||||
Command::SetEdgeRoute { diagram, route }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let route = &project.diagrams[&view].routes[0];
|
||||
assert_eq!(
|
||||
route.source_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
route.target_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 1.0
|
||||
})
|
||||
);
|
||||
for text in [SOURCE.to_owned(), language::format(&project)] {
|
||||
assert_eq!(language::parse(&text).unwrap(), project);
|
||||
}
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
|
||||
project
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(&project), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
project
|
||||
);
|
||||
|
||||
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
|
||||
assert_eq!(automatic.source_attachment, None);
|
||||
assert_eq!(automatic.target_attachment, None);
|
||||
let encoded = serde_json::to_value(automatic).unwrap();
|
||||
assert!(encoded.get("source_attachment").is_none());
|
||||
assert!(encoded.get("target_attachment").is_none());
|
||||
let mut with_null = encoded.clone();
|
||||
with_null["source_attachment"] = Value::Null;
|
||||
with_null["target_attachment"] = Value::Null;
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
|
||||
*automatic
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
|
||||
*automatic
|
||||
);
|
||||
|
||||
// Old source without either new field still means automatic presentation.
|
||||
let old = SOURCE
|
||||
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
|
||||
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
|
||||
let old = language::parse(&old).unwrap();
|
||||
assert!(
|
||||
old.diagrams
|
||||
.values()
|
||||
.flat_map(|d| &d.routes)
|
||||
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
|
||||
);
|
||||
assert!(!language::format(&old).contains("_attachment"));
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
|
||||
old
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
|
||||
let project = original();
|
||||
let edge = id(&project, "signal");
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"side":"Top"}),
|
||||
json!({"offset":0.5}),
|
||||
json!({"side":"Diagonal","offset":0.5}),
|
||||
json!({"side":"Top","offset":null}),
|
||||
json!({"side":"Top","offset":0.5,"unknown":true}),
|
||||
json!({"side":"Top","offset":"NaN"}),
|
||||
] {
|
||||
for field in ["source_attachment", "target_attachment"] {
|
||||
let mut route = json!({"edge":edge});
|
||||
route[field] = bad.clone();
|
||||
assert!(
|
||||
serde_json::from_value::<EdgeRoute>(route).is_err(),
|
||||
"{field}: {bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for bad in [
|
||||
"{}",
|
||||
"{ side Top; }",
|
||||
"{ offset 0.5; }",
|
||||
"{ side Diagonal; offset 0.5; }",
|
||||
"{ side Top; offset null; }",
|
||||
"{ side Top; offset 0.5; unknown true; }",
|
||||
"{ side Top; offset -0.01; }",
|
||||
"{ side Top; offset 1.01; }",
|
||||
"{ side Top; offset NaN; }",
|
||||
"{ side Top; offset 1e999; }",
|
||||
] {
|
||||
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
|
||||
assert!(language::parse(&source).is_err(), "{bad}");
|
||||
let bundle = SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([("p.arcadia".into(), source)]),
|
||||
};
|
||||
assert!(
|
||||
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
for version in [1, 2] {
|
||||
let legacy =
|
||||
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
|
||||
assert!(
|
||||
language::parse(&legacy).is_err(),
|
||||
"legacy route extension must not be silently admitted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let first = id(&project, "first");
|
||||
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
|
||||
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
||||
let mut route = project.diagrams[&view].routes[0].clone();
|
||||
route.set_attachment(
|
||||
end,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset,
|
||||
}),
|
||||
);
|
||||
let mut session = Session::new(project.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Rename(first, "Must roll back".into()),
|
||||
set(view, route)
|
||||
]))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &project);
|
||||
assert!(!session.can_undo() && !session.can_redo());
|
||||
}
|
||||
}
|
||||
for offset in [-0.01, 1.01] {
|
||||
let mut value = serde_json::to_value(&project).unwrap();
|
||||
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
|
||||
json!(offset);
|
||||
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let edge = id(&project, "signal");
|
||||
let mut session = Session::new(project.clone());
|
||||
let mut route = project.diagrams[&view].routes[0].clone();
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.75,
|
||||
});
|
||||
let command = set(view, route.clone());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
|
||||
command
|
||||
);
|
||||
session.execute(command).unwrap();
|
||||
let mut expected = project.clone();
|
||||
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&expected,
|
||||
"only this view/end changes; semantic fields and other view stay exact"
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &project);
|
||||
assert!(!session.can_undo() && session.can_redo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
route.source_attachment = None;
|
||||
session.execute(set(view, route.clone())).unwrap();
|
||||
let mut reset = expected.clone();
|
||||
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&reset,
|
||||
"opposite attachment, pins and label survive endpoint reset"
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session
|
||||
.execute(Command::RemoveEdgeRoute {
|
||||
diagram: view,
|
||||
edge,
|
||||
})
|
||||
.unwrap();
|
||||
let mut removed = expected.clone();
|
||||
removed.diagrams.get_mut(&view).unwrap().routes.clear();
|
||||
assert_eq!(session.project(), &removed);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
|
||||
let current = compile_bundle(&SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
|
||||
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
|
||||
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap();
|
||||
let mut project = current.decoded.project.clone();
|
||||
let view = id(&project, "view");
|
||||
project.diagrams.get_mut(&view).unwrap().routes[0]
|
||||
.source_attachment
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.offset = 0.375;
|
||||
let changed = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(changed.decoded.project, project);
|
||||
for path in ["p.arcadia", "model.arcadia"] {
|
||||
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
|
||||
}
|
||||
assert_eq!(
|
||||
changed.bundle.files["views.arcadia"],
|
||||
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&changed.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
changed.bundle
|
||||
);
|
||||
assert_eq!(
|
||||
update_bundle(&changed, &project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
changed.bundle
|
||||
);
|
||||
}
|
||||
@@ -441,6 +441,8 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
|
||||
diagram: d,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![pos(500., 420.)],
|
||||
label: None,
|
||||
},
|
||||
|
||||
@@ -70,6 +70,8 @@ fn fixture() -> Fixture {
|
||||
fn route(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
@@ -689,6 +691,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
|
||||
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
|
||||
EdgeRoute {
|
||||
edge: f.edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView {
|
||||
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins,
|
||||
label,
|
||||
});
|
||||
|
||||
@@ -117,6 +117,8 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: fe,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: Some(authored.clone()),
|
||||
});
|
||||
|
||||
@@ -301,6 +301,8 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
|
||||
let pins = vec![point(690., 480.), point(690., 230.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: out,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: None,
|
||||
});
|
||||
@@ -362,11 +364,15 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
|
||||
let pins = vec![point(570., 460.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.local,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: Some(label.clone()),
|
||||
});
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.external,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![point(690., 230.)],
|
||||
label: None,
|
||||
});
|
||||
@@ -422,6 +428,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
|
||||
let placement = f.p.place(f.d, orphan, point(30., 750.));
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
});
|
||||
@@ -456,6 +464,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
|
||||
diagram: other,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
},
|
||||
@@ -583,6 +593,8 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
|
||||
let pins = vec![point(730., 130.), point(1060., 130.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.self_edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.25,
|
||||
@@ -652,6 +664,8 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
|
||||
let pins = vec![point(280., 390.), point(280., 470.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.local,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: None,
|
||||
});
|
||||
@@ -730,6 +744,8 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
|
||||
);
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.self_edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.3,
|
||||
|
||||
@@ -748,6 +748,8 @@ fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() {
|
||||
fn route(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![xy(420., 400.), xy(420., 550.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
|
||||
@@ -70,6 +70,7 @@ impl Root<'_> {
|
||||
pan: self.app.pan,
|
||||
context: self.app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: false,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -22,6 +22,8 @@ pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
pub(crate) mod text_workspace;
|
||||
#[cfg(test)]
|
||||
mod traceability_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
mod view_copy_tests;
|
||||
@@ -359,6 +361,7 @@ pub enum Message {
|
||||
Page(Page),
|
||||
Guide(crate::guide::Event),
|
||||
Traceability(crate::views::traceability::Event),
|
||||
ReviewLayerTraces,
|
||||
Behavior(crate::behavior::Event),
|
||||
Layer(Layer),
|
||||
Select(Id),
|
||||
@@ -493,6 +496,7 @@ pub struct App {
|
||||
pub camera_epoch: u64,
|
||||
camera_boundary_epoch: u64,
|
||||
pub selected_bend: Option<(Id, usize)>,
|
||||
pub selected_attachment: Option<(crate::route_edit::Context, Id, EdgeEnd)>,
|
||||
pub exact_strings: bool,
|
||||
pub document: Document,
|
||||
pub page: Page,
|
||||
@@ -613,6 +617,7 @@ impl App {
|
||||
camera_epoch: 0,
|
||||
camera_boundary_epoch: 0,
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
exact_strings: false,
|
||||
document,
|
||||
page: Page::Diagram,
|
||||
@@ -1103,6 +1108,9 @@ impl App {
|
||||
previous != (self.layer, self.active_diagram)
|
||||
}
|
||||
fn select(&mut self, id: Id) {
|
||||
if id != self.selected {
|
||||
self.selected_attachment = None;
|
||||
}
|
||||
if self.selected == id {
|
||||
return;
|
||||
}
|
||||
@@ -2305,7 +2313,30 @@ impl App {
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
self.select(menu.request.object);
|
||||
// Show acts on the diagram captured by the validated menu.
|
||||
// Ordinary cross-layer selection would switch to that element's
|
||||
// preferred same-layer view before the placement command runs.
|
||||
let show_in_mixed_view = matches!(&*action, Message::PlaceSelected)
|
||||
&& self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.is_some_and(|d| {
|
||||
d.projection == ProjectionKind::Traceability
|
||||
&& self
|
||||
.project()
|
||||
.elements
|
||||
.get(&menu.request.object)
|
||||
.is_some_and(|e| {
|
||||
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();
|
||||
} else {
|
||||
self.select(menu.request.object);
|
||||
}
|
||||
self.selected_node = menu.request.placement;
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
@@ -2329,6 +2360,15 @@ impl App {
|
||||
} else if self.behavior.creation.has_pending() {
|
||||
self.behavior.creation.acknowledge_commit();
|
||||
self.status = "Behavior creation cancelled".into();
|
||||
} else if self.page == Page::Behavior
|
||||
&& matches!(
|
||||
self.behavior.diagram.tool,
|
||||
behavior::state_edit::Tool::AdjustAttachment { .. }
|
||||
)
|
||||
{
|
||||
// Menu arming invalidates canvas keyboard ownership. Escape
|
||||
// must also cancel before the next canvas click grants it.
|
||||
self.behavior.diagram.tool = behavior::state_edit::Tool::Select;
|
||||
}
|
||||
if self.connection.take().is_some() {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
@@ -2494,6 +2534,23 @@ impl App {
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
);
|
||||
}
|
||||
Message::ReviewLayerTraces => {
|
||||
// Reuse Page navigation so modal, placement and behavior-form
|
||||
// guards run before changing the transient inventory filter.
|
||||
let navigation = self.update(Message::Page(Page::Traces));
|
||||
if self.page == Page::Traces && !self.behavior.has_pending() {
|
||||
self.traceability.filter = crate::views::traceability::Filter::Traces;
|
||||
self.traceability.query.clear();
|
||||
return Task::batch([
|
||||
navigation,
|
||||
iced::widget::operation::snap_to(
|
||||
"traceability-list",
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
),
|
||||
]);
|
||||
}
|
||||
return navigation;
|
||||
}
|
||||
Message::Search(value) => {
|
||||
self.search = value;
|
||||
self.explorer_page = 0;
|
||||
@@ -4447,6 +4504,14 @@ impl App {
|
||||
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
||||
return;
|
||||
}
|
||||
if tool == crate::connection_tool::Tool::Allocation
|
||||
&& !tool.available_in(
|
||||
self.layer,
|
||||
self.project().diagrams[&context.diagram].projection,
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.route_context().unwrap();
|
||||
self.connection = Some(Session::new(
|
||||
@@ -4477,14 +4542,32 @@ impl App {
|
||||
if let Some(source) = session.source {
|
||||
match session.tool.command(self.project(), source, id) {
|
||||
Ok(command) => {
|
||||
let previous_layer = self.layer;
|
||||
if session.tool == crate::connection_tool::Tool::Allocation {
|
||||
// Accepted edits belong to the captured view. A
|
||||
// retained mixed view can differ from the current
|
||||
// perspective; sync must not choose another view.
|
||||
self.layer = self.project().diagrams[&context.diagram].layer;
|
||||
}
|
||||
if self.execute(command) {
|
||||
self.selected_node = None;
|
||||
self.inspector_tab = Default::default();
|
||||
self.status = format!(
|
||||
"{} created · edit its name and carried items in Properties · Undo restores the complete operation",
|
||||
session.tool.label()
|
||||
);
|
||||
self.status = if session.tool
|
||||
== crate::connection_tool::Tool::Allocation
|
||||
{
|
||||
format!(
|
||||
"Allocated {} to {} · view filters are unchanged · Undo restores the complete operation",
|
||||
self.project().named(id),
|
||||
self.project().named(source)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} created · edit its name and carried items in Properties · Undo restores the complete operation",
|
||||
session.tool.label()
|
||||
)
|
||||
};
|
||||
} else {
|
||||
self.layer = previous_layer;
|
||||
self.connection = Some(session);
|
||||
}
|
||||
}
|
||||
@@ -4499,7 +4582,18 @@ impl App {
|
||||
&project_view(self.project(), context.diagram),
|
||||
id,
|
||||
);
|
||||
self.select(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);
|
||||
}
|
||||
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()
|
||||
} else {
|
||||
@@ -4523,7 +4617,7 @@ impl App {
|
||||
}
|
||||
fn edit_route(&mut self, event: crate::route_edit::Event) {
|
||||
use crate::route_edit::{Event, insert_pin, stored_route};
|
||||
if self.connection.is_some() {
|
||||
if self.connection.is_some() || self.page != Page::Diagram {
|
||||
return;
|
||||
}
|
||||
let Some(context) = self.route_context() else {
|
||||
@@ -4551,6 +4645,75 @@ impl App {
|
||||
.filter(|(id, _)| *id == self.selected)
|
||||
.map(|(_, i)| i);
|
||||
let command = match event {
|
||||
Event::ChooseAttachment(end) => {
|
||||
if crate::route_edit::attachment_node(
|
||||
self.project(),
|
||||
diagram,
|
||||
&view,
|
||||
self.selected,
|
||||
end,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
self.selected_attachment = Some((context, self.selected, end));
|
||||
self.selected_bend = None;
|
||||
self.status = format!(
|
||||
"Drag the {} attachment handle along its element boundary · Esc cancels",
|
||||
if end == EdgeEnd::Source {
|
||||
"source"
|
||||
} else {
|
||||
"target"
|
||||
}
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Event::ResetAttachment(end) => {
|
||||
if crate::route_edit::attachment_node(
|
||||
self.project(),
|
||||
diagram,
|
||||
&view,
|
||||
self.selected,
|
||||
end,
|
||||
)
|
||||
.is_none()
|
||||
|| route.attachment(end).is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
route.set_attachment(end, None);
|
||||
selected_bend = None;
|
||||
Command::SetEdgeRoute {
|
||||
diagram: context.diagram,
|
||||
route,
|
||||
}
|
||||
}
|
||||
Event::CommitAttachment(expected, changed, end, owner) => {
|
||||
if expected != context
|
||||
|| changed.edge != self.selected
|
||||
|| crate::route_edit::attachment_node(
|
||||
self.project(),
|
||||
diagram,
|
||||
&view,
|
||||
self.selected,
|
||||
end,
|
||||
)
|
||||
.is_none_or(|node| node.id != owner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A boundary gesture owns exactly one presentation field.
|
||||
let mut intended = route.clone();
|
||||
intended.set_attachment(end, changed.attachment(end));
|
||||
if changed != intended || intended == route {
|
||||
return;
|
||||
}
|
||||
selected_bend = None;
|
||||
Command::SetEdgeRoute {
|
||||
diagram: context.diagram,
|
||||
route: intended,
|
||||
}
|
||||
}
|
||||
Event::SelectBend(id, index) => {
|
||||
if id == self.selected && index < route.waypoints.len() {
|
||||
self.selected_bend = Some((id, index));
|
||||
|
||||
@@ -69,6 +69,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -301,6 +301,17 @@ impl App {
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
|
||||
let filter = if matches!(intent, Intent::Existing { .. })
|
||||
&& self
|
||||
.project()
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.is_some_and(|d| d.projection == ProjectionKind::Traceability)
|
||||
{
|
||||
Filter::AllCompatible
|
||||
} else {
|
||||
Filter::Context
|
||||
};
|
||||
let saved = Saved {
|
||||
version: 1,
|
||||
base: oa_core::transitions::project_fingerprint(self.project()),
|
||||
@@ -309,7 +320,7 @@ impl App {
|
||||
point,
|
||||
placing,
|
||||
query: String::new(),
|
||||
filter: Filter::Context,
|
||||
filter,
|
||||
show_placed: false,
|
||||
page: 0,
|
||||
};
|
||||
|
||||
@@ -229,13 +229,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
content = content.push(group("Connections"));
|
||||
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
|
||||
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
|
||||
let kind = match connection {
|
||||
crate::connection_tool::Tool::Interaction => Kind::Activity,
|
||||
crate::connection_tool::Tool::FunctionalExchange => Kind::Function,
|
||||
crate::connection_tool::Tool::CommunicationMean => Kind::Entity,
|
||||
crate::connection_tool::Tool::BoundaryExchange => Kind::System,
|
||||
};
|
||||
if !d.projection.includes(kind) {
|
||||
if !connection.available_in(app.layer, d.projection) {
|
||||
continue;
|
||||
}
|
||||
content = content.push(tool(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
mod traceability;
|
||||
|
||||
fn setup(layer: Layer) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
@@ -75,6 +76,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn traceability_existing_defaults_show_opposite_layer_and_place_only_one_node() {
|
||||
let mut project = Project::empty();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Deliver report", None)
|
||||
.unwrap();
|
||||
let system = project
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Event::Existing(None));
|
||||
assert_eq!(
|
||||
app.architecture.as_ref().unwrap().saved.filter,
|
||||
Filter::AllCompatible
|
||||
);
|
||||
let listed = candidates(&app);
|
||||
assert!(listed.iter().any(|(id, _, shown)| *id == sa && !shown));
|
||||
assert!(listed.iter().any(|(id, _, shown)| *id == system && !shown));
|
||||
send(&mut app, Event::Pick(sa));
|
||||
assert!(app.architecture.as_ref().unwrap().saved.placing);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
let context = app.route_context().unwrap();
|
||||
let point = Position { x: 345., y: 215. };
|
||||
send(&mut app, Event::Click(context, point));
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
let placed = app.project().diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == sa)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(placed.position, point);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(placed.id, placed);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceability_default_does_not_change_creation_or_ordinary_picker_guards() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let diagram = app.active_diagram.unwrap();
|
||||
send(&mut app, Event::Existing(None));
|
||||
assert_eq!(
|
||||
app.architecture.as_ref().unwrap().saved.filter,
|
||||
Filter::Context
|
||||
);
|
||||
send(&mut app, Event::Cancel);
|
||||
let _ = app.update(Message::Projection(ProjectionKind::Traceability));
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
assert_eq!(
|
||||
app.architecture.as_ref().unwrap().saved.filter,
|
||||
Filter::Context
|
||||
);
|
||||
send(&mut app, Event::Cancel);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
|
||||
let mut project = app.project().clone();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Other layer", None)
|
||||
.unwrap();
|
||||
let port = Id::new_v4();
|
||||
project.elements.insert(
|
||||
port,
|
||||
oa_core::Element {
|
||||
id: port,
|
||||
alias: "legacy_port".into(),
|
||||
name: "Port".into(),
|
||||
description: String::new(),
|
||||
parent: Some(sa),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
oa_core::validate_candidate(&project).unwrap();
|
||||
for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] {
|
||||
let mut p = project.clone();
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = projection;
|
||||
let mut trial = App::with_project(p);
|
||||
trial.page = Page::Diagram;
|
||||
trial.active_diagram = Some(diagram);
|
||||
trial.layer = Layer::Oa;
|
||||
let before = trial.document.bundle().clone();
|
||||
send(&mut trial, Event::Existing(None));
|
||||
send(&mut trial, Event::Filter(Filter::AllCompatible));
|
||||
assert_eq!(
|
||||
candidates(&trial).iter().any(|(id, _, _)| *id == sa),
|
||||
projection == ProjectionKind::Traceability
|
||||
);
|
||||
assert!(!candidates(&trial).iter().any(|(id, _, _)| *id == port));
|
||||
send(&mut trial, Event::Pick(port));
|
||||
assert!(!trial.architecture.as_ref().unwrap().saved.placing);
|
||||
send(&mut trial, Event::Cancel);
|
||||
assert_eq!(trial.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: true,
|
||||
connection: None,
|
||||
@@ -306,6 +307,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -924,6 +924,8 @@ async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed(
|
||||
diagram,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 400., y: -100. }],
|
||||
label: None,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Production App tree and its actual keyboard subscription, without a canvas click.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{
|
||||
advanced::subscription,
|
||||
futures::{StreamExt, stream, task::noop_waker_ref},
|
||||
keyboard::key::Named,
|
||||
mouse,
|
||||
};
|
||||
use std::task::{Context as PollContext, Poll};
|
||||
|
||||
async fn press(mounted: &mut Mounted, app: &mut App, named: Named) -> Vec<Message> {
|
||||
let input = key(named);
|
||||
let (mut messages, statuses) = mounted.input_with_status(
|
||||
app,
|
||||
std::slice::from_ref(&input),
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
let interaction = subscription::Event::Interaction {
|
||||
window: iced::window::Id::unique(),
|
||||
event: input,
|
||||
status: statuses[0],
|
||||
};
|
||||
for recipe in subscription::into_recipes(app.subscription()) {
|
||||
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
|
||||
let mut cx = PollContext::from_waker(noop_waker_ref());
|
||||
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
for message in messages.iter().cloned() {
|
||||
let task = app.update(message);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
mounted.settle(app).await;
|
||||
messages
|
||||
}
|
||||
|
||||
async fn armed() -> (App, Mounted, Id) {
|
||||
let mut f = fixture();
|
||||
let diagram = create_view(&mut f).diagram;
|
||||
let mut project = f.app.project().clone();
|
||||
let relation = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
|
||||
let mut app = App::with_project(project);
|
||||
app.window_size = iced::Size::new(1800., 1100.);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, relation));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
app.behavior.diagram.fit = false;
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::ContextMenu {
|
||||
context,
|
||||
object: relation,
|
||||
placement: None,
|
||||
anchor: iced::Point::new(450., 400.),
|
||||
bend: None,
|
||||
route_segment: None,
|
||||
},
|
||||
);
|
||||
mounted.settle(&mut app).await;
|
||||
let token = app.model_menu.as_ref().unwrap().token;
|
||||
send(
|
||||
&mut app,
|
||||
Message::Menu(crate::model_menu::Event::Invoke(
|
||||
token,
|
||||
Box::new(Message::StateDiagram(Event::Choose(
|
||||
Tool::AdjustAttachment {
|
||||
relation,
|
||||
end: EdgeEnd::Source,
|
||||
},
|
||||
))),
|
||||
)),
|
||||
);
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(app.model_menu.is_none());
|
||||
assert_eq!(
|
||||
app.behavior.diagram.tool,
|
||||
Tool::AdjustAttachment {
|
||||
relation,
|
||||
end: EdgeEnd::Source
|
||||
}
|
||||
);
|
||||
(app, mounted, relation)
|
||||
}
|
||||
|
||||
fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) {
|
||||
let id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
|
||||
let (_, bounds, focused) = mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.find(|(candidate, _, _)| candidate.as_ref() == Some(&id))
|
||||
.unwrap();
|
||||
(bounds, focused)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() {
|
||||
let (mut app, mut mounted, _) = armed().await;
|
||||
let before = app.document.snapshot();
|
||||
let history = (app.document.can_undo(), app.document.can_redo());
|
||||
let camera = (
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit,
|
||||
);
|
||||
assert!(!canvas_focus(&mut mounted, &app).1);
|
||||
let messages = press(&mut mounted, &mut app, Named::Escape).await;
|
||||
assert!(messages.iter().any(|m| matches!(m, Message::Escape)));
|
||||
assert_eq!(app.behavior.diagram.tool, Tool::Select);
|
||||
assert!(!canvas_focus(&mut mounted, &app).1);
|
||||
press(&mut mounted, &mut app, Named::Delete).await;
|
||||
assert!(
|
||||
!app.delete_pending,
|
||||
"Escape must not grant canvas Delete ownership"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit
|
||||
),
|
||||
camera
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() {
|
||||
let (mut app, mut mounted, _) = armed().await;
|
||||
let before = app.document.snapshot();
|
||||
// Existing modal Escape consumes the first key without canceling the tool underneath.
|
||||
send(&mut app, Message::Delete);
|
||||
assert!(app.delete_pending && app.delete_target.is_some());
|
||||
mounted.settle(&mut app).await;
|
||||
press(&mut mounted, &mut app, Named::Escape).await;
|
||||
assert!(!app.delete_pending);
|
||||
assert!(matches!(
|
||||
app.behavior.diagram.tool,
|
||||
Tool::AdjustAttachment { .. }
|
||||
));
|
||||
// A real canvas click grants ownership; inner-canvas Escape remains live.
|
||||
let bounds = canvas_focus(&mut mounted, &app).0;
|
||||
mounted
|
||||
.click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.))
|
||||
.await;
|
||||
assert!(canvas_focus(&mut mounted, &app).1);
|
||||
let messages = press(&mut mounted, &mut app, Named::Escape).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select))))
|
||||
);
|
||||
assert_eq!(app.behavior.diagram.tool, Tool::Select);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn state_bend_delayed_commit_after_same_view_selection_is_inert_and_live_commit_works() {
|
||||
let (mut app, _, relation, _) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let next = app.project().relations[&relation].target();
|
||||
let waypoints = vec![Position { x: 715., y: -425. }];
|
||||
let delayed = Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: waypoints.clone(),
|
||||
};
|
||||
send(&mut app, Message::Select(next));
|
||||
assert_eq!(app.selected, next);
|
||||
assert_eq!(
|
||||
app.state_context(),
|
||||
Some(context),
|
||||
"same-view selection does not retire the context stamp"
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let camera = (
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit,
|
||||
);
|
||||
event(&mut app, delayed);
|
||||
assert_eq!(app.selected, next);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit
|
||||
),
|
||||
camera
|
||||
);
|
||||
send(&mut app, Message::Select(relation));
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: waypoints.clone(),
|
||||
},
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let route = expected
|
||||
.diagrams
|
||||
.get_mut(&context.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == relation)
|
||||
.unwrap();
|
||||
route.waypoints = waypoints;
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_source_model_agree(&app);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"live drag remains a single transaction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action() {
|
||||
let (mut app, _, relation, _) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let point = Position { x: 500., y: -400. };
|
||||
event(
|
||||
&mut app,
|
||||
Event::ContextMenu {
|
||||
context,
|
||||
object: relation,
|
||||
placement: None,
|
||||
anchor: iced::Point::ORIGIN,
|
||||
bend: Some(1),
|
||||
route_segment: Some((2, point)),
|
||||
},
|
||||
);
|
||||
let menu = crate::model_menu::popup(&app).unwrap();
|
||||
let mut add = false;
|
||||
let mut remove = false;
|
||||
let mut delete = false;
|
||||
for entry in menu.entries {
|
||||
if let crate::context_menu::Entry::Action {
|
||||
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, message))),
|
||||
..
|
||||
} = entry
|
||||
{
|
||||
match *message {
|
||||
Message::StateDiagram(Event::AddBend {
|
||||
relation: id,
|
||||
segment: Some((2, at)),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(id, relation);
|
||||
assert_eq!(at, point);
|
||||
add = true;
|
||||
}
|
||||
Message::StateDiagram(Event::RemoveBend {
|
||||
relation: id,
|
||||
index: 1,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(id, relation);
|
||||
remove = true;
|
||||
}
|
||||
Message::Delete => delete = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(add && remove && delete);
|
||||
}
|
||||
|
||||
fn setup() -> (App, Id, Id, Id) {
|
||||
let mut f = fixture();
|
||||
let diagram = create_view(&mut f).diagram;
|
||||
let mut project = f.app.project().clone();
|
||||
let relation = project.relate(RelationKind::StateTransition, "Bend trial", f.leaf, f.b);
|
||||
let second = state_edit::create_view(&project, f.machine).unwrap();
|
||||
project = prepare_command(&project, &second.command).unwrap().project;
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: relation,
|
||||
waypoints: vec![
|
||||
Position { x: 650., y: -400. },
|
||||
Position { x: 300., y: -400. },
|
||||
Position { x: 650., y: -400. },
|
||||
],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
offset: Position { x: 9., y: -23. },
|
||||
}),
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.234567,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.75,
|
||||
}),
|
||||
});
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, relation));
|
||||
(app, diagram, relation, f.a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_commands_preserve_other_fields_views_and_exact_history() {
|
||||
let (mut app, diagram, relation, _) = setup();
|
||||
let baseline = app.document.snapshot();
|
||||
let points = vec![Position { x: 715., y: 425. }];
|
||||
let mut expected = app.project().clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points.clone();
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: points.clone(),
|
||||
},
|
||||
);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_source_model_agree(&app);
|
||||
let changed = app.document.snapshot();
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: points,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&changed, &app.document.snapshot()),
|
||||
"equal bends must not add history"
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &changed.bundle);
|
||||
let context = app.state_context().unwrap();
|
||||
event(&mut app, Event::ClearBends { context, relation });
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.clear();
|
||||
assert_eq!(app.project(), &expected);
|
||||
let cleared = app.document.snapshot();
|
||||
let context = app.state_context().unwrap();
|
||||
event(&mut app, Event::ClearBends { context, relation });
|
||||
assert!(Arc::ptr_eq(&cleared, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_self_loop_add_move_remove_clear_preserves_project_and_source_history() {
|
||||
let (app, diagram, relation, _) = setup();
|
||||
let mut project = app.project().clone();
|
||||
let RelationBody::StateTransition { source, target, .. } =
|
||||
&mut project.relations.get_mut(&relation).unwrap().body
|
||||
else {
|
||||
panic!("State transition fixture")
|
||||
};
|
||||
*target = *source;
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.clear();
|
||||
oa_core::validate_candidate(&project).unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, relation));
|
||||
let baseline = app.document.snapshot();
|
||||
let geometry = state_edit::bend_route(app.project(), diagram, relation).unwrap();
|
||||
assert!(!geometry.custom_suspended);
|
||||
assert!(geometry.pins.is_empty());
|
||||
assert!(
|
||||
geometry.points.len() >= 4,
|
||||
"actual external self-loop routing"
|
||||
);
|
||||
let segment = 1;
|
||||
let point = Position {
|
||||
x: (geometry.points[segment].x + geometry.points[segment + 1].x) / 2.,
|
||||
y: (geometry.points[segment].y + geometry.points[segment + 1].y) / 2.,
|
||||
};
|
||||
|
||||
// Each operation must alter only the complete Project's waypoint list;
|
||||
// Undo and Redo must restore exact serialized source, not just geometry.
|
||||
fn check(app: &mut App, diagram: Id, mutation: Event, points: Vec<Position>) {
|
||||
let before = app.document.snapshot();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points;
|
||||
event(app, mutation);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_source_model_agree(app);
|
||||
let after = app.document.snapshot();
|
||||
assert_ne!(after.bundle, before.bundle);
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
send(app, Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((segment, point)),
|
||||
},
|
||||
vec![point],
|
||||
);
|
||||
let moved = Position {
|
||||
x: point.x + 45.,
|
||||
y: point.y - 35.,
|
||||
};
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: vec![moved],
|
||||
},
|
||||
vec![moved],
|
||||
);
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: 0,
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
send(&mut app, Message::Undo);
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::ClearBends { context, relation },
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_source_model_agree(&app);
|
||||
// Clear is a new source-preserving edit and may retain empty-list trivia.
|
||||
// Exact original bytes are restored by history, not by reauthoring a value.
|
||||
for _ in 0..3 {
|
||||
assert!(app.document.can_undo());
|
||||
send(&mut app, Message::Undo);
|
||||
}
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_insertion_uses_captured_traversal_segment_and_removal_only_changes_pins() {
|
||||
let (app, diagram, relation, _) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let p = app.project();
|
||||
let geometry = state_edit::bend_route(p, diagram, relation).unwrap();
|
||||
let original = &p.diagrams[&diagram].routes[0];
|
||||
let reverse = geometry
|
||||
.points
|
||||
.windows(2)
|
||||
.position(|pair| pair[0] == original.waypoints[0] && pair[1] == original.waypoints[1])
|
||||
.unwrap();
|
||||
for (segment, expected_index) in [(reverse, 1), (reverse + 1, 2)] {
|
||||
let point = Position { x: 500., y: -400. };
|
||||
let planned = state_edit::plan(
|
||||
p,
|
||||
context,
|
||||
&Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((segment, point)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let actual = prepare_command(p, &planned.command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.insert(expected_index, point);
|
||||
assert_eq!(actual, expected);
|
||||
let removed = state_edit::plan(
|
||||
&actual,
|
||||
context,
|
||||
&Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: expected_index,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prepare_command(&actual, &removed.command).unwrap().project,
|
||||
*p
|
||||
);
|
||||
}
|
||||
let default = state_edit::plan(
|
||||
p,
|
||||
context,
|
||||
&Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let Command::SetEdgeRoute { route, .. } = default.command else {
|
||||
panic!("route command")
|
||||
};
|
||||
assert_eq!(route.waypoints.len(), original.waypoints.len() + 1);
|
||||
assert_eq!(route.source_attachment, original.source_attachment);
|
||||
assert_eq!(route.target_attachment, original.target_attachment);
|
||||
assert_eq!(route.label, original.label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_stale_nonfinite_capacity_hidden_and_folded_requests_are_inert() {
|
||||
let (mut app, diagram, relation, composite) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let baseline = app.document.snapshot();
|
||||
for mutation in [
|
||||
Event::SetBends {
|
||||
context: Context {
|
||||
epoch: context.epoch + 1,
|
||||
..context
|
||||
},
|
||||
relation,
|
||||
waypoints: vec![],
|
||||
},
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: vec![Position { x: f32::NAN, y: 0. }],
|
||||
},
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: vec![Position { x: 0., y: 0. }; 129],
|
||||
},
|
||||
Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((usize::MAX, Position { x: 0., y: 0. })),
|
||||
},
|
||||
Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: usize::MAX,
|
||||
},
|
||||
] {
|
||||
event(&mut app, mutation);
|
||||
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
}
|
||||
let mut p = app.project().clone();
|
||||
p.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints =
|
||||
vec![Position { x: 500., y: -400. }; 128];
|
||||
assert!(
|
||||
state_edit::plan(
|
||||
&p,
|
||||
context,
|
||||
&Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((1, Position { x: 500., y: -400. }))
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut hidden = app.project().clone();
|
||||
hidden
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(relation);
|
||||
assert!(state_edit::bend_route(&hidden, diagram, relation).is_none());
|
||||
assert!(state_edit::plan(&hidden, context, &Event::ClearBends { context, relation }).is_err());
|
||||
let owner = node(&app, diagram, composite);
|
||||
let mut folded = app.project().clone();
|
||||
folded
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert!(state_edit::bend_route(&folded, diagram, relation).is_none());
|
||||
assert!(state_edit::plan(&folded, context, &Event::ClearBends { context, relation }).is_err());
|
||||
let mut internal = app.project().clone();
|
||||
internal.diagrams.get_mut(&diagram).unwrap().routes.clear();
|
||||
if let RelationBody::StateTransition {
|
||||
source,
|
||||
target,
|
||||
transition_kind,
|
||||
..
|
||||
} = &mut internal.relations.get_mut(&relation).unwrap().body
|
||||
{
|
||||
*target = *source;
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
assert!(state_edit::bend_route(&internal, diagram, relation).is_none());
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Unapplied bend owner name".into()),
|
||||
);
|
||||
let draft = app.draft.as_ref().unwrap().value.clone();
|
||||
event(&mut app, Event::ClearBends { context, relation });
|
||||
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
assert_eq!(app.draft.as_ref().unwrap().value, draft);
|
||||
}
|
||||
@@ -157,10 +157,14 @@ impl App {
|
||||
object,
|
||||
placement,
|
||||
anchor,
|
||||
bend,
|
||||
route_segment,
|
||||
} => {
|
||||
let mut request = crate::model_menu::Request::object(object, anchor);
|
||||
request.canvas = Some(context);
|
||||
request.placement = placement;
|
||||
request.bend = bend;
|
||||
request.route_segment = route_segment;
|
||||
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
}
|
||||
Event::EditTransition(_, id) => {
|
||||
@@ -172,6 +176,24 @@ impl App {
|
||||
)));
|
||||
}
|
||||
Event::Choose(tool) => {
|
||||
if let Tool::AdjustAttachment { relation, end } = tool {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before adjusting an attachment.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(context) = self.state_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
if state_edit::attachment_node(self.project(), context.diagram, relation, end)
|
||||
.is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.selected = relation;
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
}
|
||||
if !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -210,8 +232,37 @@ impl App {
|
||||
let Some(context) = self.state_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
if let Event::SetBends { relation, .. } = &mutation
|
||||
&& *relation != self.selected
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Event::SetAttachment {
|
||||
relation,
|
||||
end,
|
||||
attachment,
|
||||
..
|
||||
} = &mutation
|
||||
{
|
||||
let existing = self.project().diagrams[&context.diagram]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.edge == *relation)
|
||||
.and_then(|route| route.attachment(*end));
|
||||
if existing == *attachment {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
match state_edit::plan(self.project(), context, &mutation) {
|
||||
Ok(plan) => {
|
||||
if let Command::SetEdgeRoute { diagram, route } = &plan.command
|
||||
&& crate::route_edit::stored_route(
|
||||
&self.project().diagrams[diagram],
|
||||
route.edge,
|
||||
) == *route
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.execute(plan.command) {
|
||||
self.behavior.diagram.diagram = Some(plan.diagram);
|
||||
self.behavior.diagram.placement = plan.placement;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! App transaction and draft boundaries for captured state-canvas messages.
|
||||
use super::*;
|
||||
#[path = "state_attachment_escape_tests.rs"]
|
||||
mod attachment_escape;
|
||||
#[path = "state_bend_tests.rs"]
|
||||
mod bends;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
state_edit::{self, Context, Endpoint, Event, Tool},
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
use crate::{context_menu::Entry, model_menu, views::traceability::Filter};
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Observe", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Detect", None)
|
||||
.unwrap();
|
||||
let mixed = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability;
|
||||
p.place(mixed, oa, Position { x: 300., y: 200. });
|
||||
let system = p.ensure_diagram(Layer::Sa);
|
||||
p.place(system, sa, Position { x: 700., y: 200. });
|
||||
oa_core::validate_candidate(&p).unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(mixed);
|
||||
app.selected = oa;
|
||||
app.refresh_draft();
|
||||
app.traceability.filter = Filter::Artifacts;
|
||||
app.traceability.query = "stale artifact query".into();
|
||||
(app, mixed, system, oa, sa)
|
||||
}
|
||||
|
||||
fn show_action(app: &mut App, element: Id) -> Message {
|
||||
send(app, model_menu::open(element, iced::Point::ORIGIN));
|
||||
model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action { label, message, .. } if label == "Show in this diagram" => {
|
||||
Some(message)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("eligible real menu action")
|
||||
.expect("eligible menu action must be enabled")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_layer_traces_resets_inventory_without_applying_property_or_source_drafts() {
|
||||
for source_draft in [false, true] {
|
||||
let (mut app, mixed, _, oa, _) = fixture();
|
||||
if source_draft {
|
||||
send(&mut app, Message::Page(Page::Text));
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Insert('x'),
|
||||
)),
|
||||
);
|
||||
assert!(app.source_dirty());
|
||||
} else {
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/alias".into(), "invalid alias".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let source = app.source_editor.text();
|
||||
let before = app.document.snapshot();
|
||||
let camera = (app.zoom, app.pan);
|
||||
send(&mut app, Message::ReviewLayerTraces);
|
||||
assert_eq!(app.page, Page::Traces);
|
||||
assert_eq!(app.traceability.filter, Filter::Traces);
|
||||
assert!(app.traceability.query.is_empty());
|
||||
assert_eq!(
|
||||
(app.layer, app.active_diagram, app.selected),
|
||||
(Layer::Oa, Some(mixed), oa)
|
||||
);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_layer_traces_respects_busy_modal_and_active_placement_guards() {
|
||||
for already_traces in [false, true] {
|
||||
for guard in 0..5 {
|
||||
let (mut app, mixed, _, oa, _) = fixture();
|
||||
match guard {
|
||||
0 => app.busy = true,
|
||||
1 => app.delete_pending = true,
|
||||
2 => app.hosted.visible = true,
|
||||
3 => send(
|
||||
&mut app,
|
||||
Message::Architecture(architecture_authoring::Event::Existing(None)),
|
||||
),
|
||||
4 => {
|
||||
app.page = Page::Behavior;
|
||||
let p = app.project().clone();
|
||||
app.behavior
|
||||
.creation
|
||||
.update(&p, behavior::creation::Event::Open(Layer::Oa))
|
||||
.unwrap();
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
if already_traces {
|
||||
app.page = Page::Traces;
|
||||
}
|
||||
let original_page = app.page;
|
||||
let before = app.document.snapshot();
|
||||
let behavior_before = app.behavior.recovery_snapshot().unwrap();
|
||||
send(&mut app, Message::ReviewLayerTraces);
|
||||
assert_eq!(
|
||||
app.page, original_page,
|
||||
"guard {guard}, already traces {already_traces}"
|
||||
);
|
||||
assert_eq!(app.traceability.filter, Filter::Artifacts);
|
||||
assert_eq!(app.traceability.query, "stale artifact query");
|
||||
assert_eq!(
|
||||
(app.layer, app.active_diagram, app.selected),
|
||||
(Layer::Oa, Some(mixed), oa)
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), behavior_before);
|
||||
if guard == 3 {
|
||||
assert!(app.architecture.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_view_show_keeps_its_context_for_every_diagram_app_and_target_layer() {
|
||||
for diagram_layer in [Layer::Oa, Layer::Sa] {
|
||||
for app_layer in [Layer::Oa, Layer::Sa] {
|
||||
for target_layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Observe", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Detect", None)
|
||||
.unwrap();
|
||||
let target = if target_layer == Layer::Oa { oa } else { sa };
|
||||
let other = if target_layer == Layer::Oa { sa } else { oa };
|
||||
let mixed = p.ensure_diagram(diagram_layer);
|
||||
p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability;
|
||||
// An attractive same-layer alternative makes accidental
|
||||
// semantic-selection navigation observable in both directions.
|
||||
let mut alternative = p.diagrams[&mixed].clone();
|
||||
alternative.id = Id::new_v4();
|
||||
alternative.alias = "same_layer_alternative".into();
|
||||
alternative.layer = target_layer;
|
||||
alternative.projection = ProjectionKind::Overview;
|
||||
let alternative_id = alternative.id;
|
||||
p.diagrams.insert(alternative_id, alternative);
|
||||
p.place(alternative_id, target, Position { x: 700., y: 200. });
|
||||
oa_core::validate_candidate(&p).unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = app_layer;
|
||||
app.active_diagram = Some(mixed);
|
||||
app.selected = other;
|
||||
app.refresh_draft();
|
||||
let before = app.project().clone();
|
||||
let action = show_action(&mut app, target);
|
||||
send(&mut app, action);
|
||||
// The existing RevealInDiagram action synchronizes the layer
|
||||
// to the captured view, even if the prior perspective differed.
|
||||
assert_eq!(
|
||||
(app.active_diagram, app.layer, app.selected),
|
||||
(Some(mixed), diagram_layer, target)
|
||||
);
|
||||
let added = app.project().diagrams[&mixed]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == target)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(app.selected_node, Some(added.id));
|
||||
let mut expected = before;
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&mixed)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(added.id, added);
|
||||
assert_eq!(app.project(), &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_view_show_menu_places_in_captured_view_with_exact_undo_redo() {
|
||||
let (mut app, mixed, system, _, sa) = fixture();
|
||||
let before = app.document.snapshot();
|
||||
let action = show_action(&mut app, sa);
|
||||
send(&mut app, action);
|
||||
assert_eq!(
|
||||
(app.active_diagram, app.layer, app.selected),
|
||||
(Some(mixed), Layer::Oa, sa)
|
||||
);
|
||||
let added = app.project().diagrams[&mixed]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == sa)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(added.position, Position { x: 60., y: 60. });
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&mixed)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(added.id, added);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&system],
|
||||
before.decoded.project.diagrams[&system]
|
||||
);
|
||||
let after = app.document.snapshot();
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &after.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_view_show_menu_rejects_stale_context_and_pending_property_changes() {
|
||||
for refusal in 0..3 {
|
||||
let (mut app, mixed, system, oa, sa) = fixture();
|
||||
let action = show_action(&mut app, sa);
|
||||
match refusal {
|
||||
0 => app.active_diagram = Some(system),
|
||||
1 => app.canvas_epoch = app.canvas_epoch.wrapping_add(1),
|
||||
2 => {
|
||||
// A queued action must not apply a draft that became dirty
|
||||
// after the menu was rendered, even when its context is valid.
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Unapplied name".into());
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let before = app.document.snapshot();
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
send(&mut app, action);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.selected, oa);
|
||||
assert_eq!(
|
||||
app.active_diagram,
|
||||
Some(if refusal == 0 { system } else { mixed })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,14 @@ impl Mounted {
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Message> {
|
||||
self.input_with_status(app, events, cursor).0
|
||||
}
|
||||
pub fn input_with_status(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> (Vec<Message>, Vec<iced::event::Status>) {
|
||||
let mut messages = vec![];
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
@@ -165,7 +173,7 @@ impl Mounted {
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
let _ = ui.update(
|
||||
let (_, statuses) = ui.update(
|
||||
events,
|
||||
cursor,
|
||||
&mut self.renderer,
|
||||
@@ -173,7 +181,7 @@ impl Mounted {
|
||||
&mut messages,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
messages
|
||||
(messages, statuses)
|
||||
}
|
||||
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
|
||||
let mut ready = vec![];
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
// Included in state_canvas::tests: exercise the real canvas event path.
|
||||
|
||||
fn bend_fixture(authored: bool) -> (Fixture, Id) {
|
||||
let mut f = fixture();
|
||||
let relation = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Standby to stopped",
|
||||
f.b,
|
||||
f.c,
|
||||
);
|
||||
if authored {
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: relation,
|
||||
waypoints: vec![Position {
|
||||
x: 650.25,
|
||||
y: 340.75,
|
||||
}],
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.5,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.43,
|
||||
offset: Position { x: 28., y: -16. },
|
||||
}),
|
||||
});
|
||||
}
|
||||
validate_candidate(&f.project).unwrap();
|
||||
(f, relation)
|
||||
}
|
||||
|
||||
fn bend_segment(scene: &Scene, relation: Id) -> (usize, Point) {
|
||||
let edge = &scene.routed.edges[&relation];
|
||||
edge.points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
|
||||
.map(|(i, p)| {
|
||||
(
|
||||
i,
|
||||
Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.),
|
||||
(p[1].x - p[0].x).abs() + (p[1].y - p[0].y).abs(),
|
||||
)
|
||||
})
|
||||
.filter(|(_, p, length)| {
|
||||
*length > 40. && edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.)
|
||||
})
|
||||
.max_by(|a, b| a.2.total_cmp(&b.2))
|
||||
.map(|(i, p, _)| (i, p))
|
||||
.expect("fixture has an exposed interior route segment")
|
||||
}
|
||||
|
||||
fn bend_press(canvas: &StateCanvas<'_>, state: &mut State, at: Point) {
|
||||
motion(canvas, state, at, at);
|
||||
let event = input(
|
||||
canvas,
|
||||
state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
at,
|
||||
);
|
||||
assert!(
|
||||
!matches!(event, Some(Event::SetBends { .. })),
|
||||
"press alone cannot commit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_segment_drag_inserts_one_pin_using_ordered_pointer_at_three_zooms() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let (f, relation) = bend_fixture(false);
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let (_, midpoint) = bend_segment(&baseline, relation);
|
||||
let start = screen(&canvas, midpoint);
|
||||
let destination = Position {
|
||||
x: ((midpoint.x + 45.) / 5.).round() * 5.,
|
||||
y: ((midpoint.y + 30.) / 5.).round() * 5.,
|
||||
};
|
||||
let release = screen(&canvas, point(destination));
|
||||
let later_batch_cursor = release + Vector::new(137., 83.);
|
||||
motion(&canvas, &mut state, start, later_batch_cursor);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
later_batch_cursor,
|
||||
);
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
motion(&canvas, &mut state, release, later_batch_cursor);
|
||||
assert_eq!(
|
||||
state.bend_preview.as_ref().unwrap().waypoints,
|
||||
vec![destination]
|
||||
);
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].pins,
|
||||
vec![destination]
|
||||
);
|
||||
let event = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
later_batch_cursor,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&event, Event::SetBends { context, relation: id, waypoints }
|
||||
if *context == canvas.context && *id == relation && *waypoints == vec![destination])
|
||||
);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
later_batch_cursor
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let after = prepare_command(
|
||||
&f.project,
|
||||
&state_edit::plan(&f.project, canvas.context, &event)
|
||||
.unwrap()
|
||||
.command,
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let stored = &after.diagrams[&f.diagram].routes[0];
|
||||
assert_eq!(stored.waypoints, vec![destination]);
|
||||
assert!(
|
||||
stored.source_attachment.is_none()
|
||||
&& stored.target_attachment.is_none()
|
||||
&& stored.label.is_none()
|
||||
);
|
||||
assert_eq!(after.relations, f.project.relations);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_authored_pin_move_preserves_full_project_and_exact_undo_redo() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let (mut f, relation) = bend_fixture(true);
|
||||
let mut other = f.project.diagrams[&f.diagram].clone();
|
||||
other.id = Id::new_v4();
|
||||
other.alias = "bend_reference_view".into();
|
||||
other.nodes = other
|
||||
.nodes
|
||||
.into_values()
|
||||
.map(|mut node| {
|
||||
node.id = Id::new_v4();
|
||||
node.alias = format!("bend_reference_{}", node.alias);
|
||||
(node.id, node)
|
||||
})
|
||||
.collect();
|
||||
let other_id = other.id;
|
||||
f.project.diagrams.insert(other_id, other.clone());
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let start = screen(&canvas, point(original.waypoints[0]));
|
||||
let destination = Position { x: 690., y: 365. };
|
||||
let release = screen(&canvas, point(destination));
|
||||
bend_press(&canvas, &mut state, start);
|
||||
motion(&canvas, &mut state, release, release);
|
||||
let preview = state.bend_preview.as_ref().unwrap();
|
||||
let mut intended = original.clone();
|
||||
intended.waypoints[0] = destination;
|
||||
assert_eq!(preview, &intended);
|
||||
assert_eq!(
|
||||
f.project.diagrams[&f.diagram].routes[0], original,
|
||||
"preview is transient"
|
||||
);
|
||||
let preview_scene = canvas.scene(&state).unwrap();
|
||||
let event = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
release,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&event, Event::SetBends { waypoints, .. } if waypoints == &intended.waypoints)
|
||||
);
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = intended;
|
||||
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
|
||||
let before = document.bundle().clone();
|
||||
document.execute(plan.command).unwrap();
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.project().diagrams[&other_id], other);
|
||||
let committed_view = projection::project_view(document.project(), f.diagram);
|
||||
let committed = routing::route_diagram(
|
||||
document.project(),
|
||||
&document.project().diagrams[&f.diagram],
|
||||
&committed_view,
|
||||
);
|
||||
let preview_edge = &preview_scene.routed.edges[&relation];
|
||||
let committed_edge = &committed.edges[&relation];
|
||||
assert_eq!(preview_edge.points, committed_edge.points);
|
||||
assert_eq!(preview_edge.pin_fractions, committed_edge.pin_fractions);
|
||||
assert_eq!(preview_edge.warnings, committed_edge.warnings);
|
||||
let edited = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &f.project);
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert!(!document.undo(), "one drag adds one history operation");
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.bundle(), &edited);
|
||||
assert!(!document.redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_stationary_and_out_and_back_restore_off_grid_or_automatic_route() {
|
||||
for authored in [false, true] {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for travel in [false, true] {
|
||||
let (f, relation) = bend_fixture(authored);
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let world = if authored {
|
||||
point(scene.routed.edges[&relation].pins[0])
|
||||
} else {
|
||||
bend_segment(&scene, relation).1
|
||||
};
|
||||
let start = screen(&canvas, world);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
if travel {
|
||||
let away = start + Vector::new(45., 30.) * zoom;
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(state.bend_preview.is_some());
|
||||
motion(&canvas, &mut state, start, start);
|
||||
}
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
start
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
scene.routed.edges[&relation].points
|
||||
);
|
||||
assert_eq!(
|
||||
f.project.diagrams[&f.diagram].routes.len(),
|
||||
usize::from(authored)
|
||||
);
|
||||
if authored {
|
||||
assert_eq!(
|
||||
f.project.diagrams[&f.diagram].routes[0].waypoints,
|
||||
vec![Position {
|
||||
x: 650.25,
|
||||
y: 340.75
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_cancel_on_camera_context_focus_and_pointer_interruptions() {
|
||||
for cancellation in 0..16 {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let start = screen(&canvas, point(original.waypoints[0]));
|
||||
let release = start + Vector::new(45., 30.);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
motion(&canvas, &mut state, release, release);
|
||||
assert!(state.bend_preview.is_some(), "case {cancellation}");
|
||||
let mut release_bounds = bounds();
|
||||
match cancellation {
|
||||
0 => canvas.zoom = 2.,
|
||||
1 => canvas.pan.x += 10.,
|
||||
2 => canvas.context.epoch += 1,
|
||||
3 => canvas.context.revision += 1,
|
||||
4 => canvas.context.document = Id::new_v4(),
|
||||
5 => canvas.tool = Tool::Transition,
|
||||
6 => canvas.editable = false,
|
||||
7 | 8 => {
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(if cancellation == 7 {
|
||||
mouse::Button::Right
|
||||
} else {
|
||||
mouse::Button::Middle
|
||||
}),
|
||||
release,
|
||||
);
|
||||
}
|
||||
9 => {
|
||||
canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(release),
|
||||
);
|
||||
}
|
||||
10 => {
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
},
|
||||
release,
|
||||
);
|
||||
}
|
||||
11 => {
|
||||
input(&canvas, &mut state, mouse::Event::CursorLeft, release);
|
||||
}
|
||||
12 => {
|
||||
motion(&canvas, &mut state, Point::new(-100., -100.), release);
|
||||
}
|
||||
13 => release_bounds.x += 25.,
|
||||
15 => canvas.selected = f.b,
|
||||
_ => {
|
||||
let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let event = canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&escape,
|
||||
bounds(),
|
||||
mouse::Cursor::Available(release),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0);
|
||||
assert!(matches!(event, Some(Event::Choose(Tool::Select))));
|
||||
}
|
||||
}
|
||||
let event = input_at_bounds(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
release,
|
||||
release_bounds,
|
||||
);
|
||||
assert!(
|
||||
!matches!(event, Some(Event::SetBends { .. })),
|
||||
"late release committed case {cancellation}"
|
||||
);
|
||||
assert!(
|
||||
state.bend_preview.is_none(),
|
||||
"stale preview survived case {cancellation}"
|
||||
);
|
||||
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let pin = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
|
||||
motion(&canvas, &mut state, pin, pin);
|
||||
assert!(
|
||||
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None })
|
||||
if context == canvas.context && object == relation && anchor == pin)
|
||||
);
|
||||
let (index, midpoint) = bend_segment(&scene, relation);
|
||||
let at = screen(&canvas, midpoint);
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(
|
||||
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)) })
|
||||
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_internal_rows_and_folded_proxies_cannot_acquire_pins() {
|
||||
for internal in [false, true] {
|
||||
let mut f = fixture();
|
||||
let relation = if internal {
|
||||
let id = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "Internal check", f.a, f.a);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut f.project.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
id
|
||||
} else {
|
||||
let id = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Hidden child to standby",
|
||||
f.leaf,
|
||||
f.b,
|
||||
);
|
||||
let parent = placement(&f, f.a);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
id
|
||||
};
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let world = if internal {
|
||||
assert!(!scene.routed.edges.contains_key(&relation));
|
||||
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
|
||||
internal_rows(&f.project, node)
|
||||
.into_iter()
|
||||
.find(|(r, _)| r.id == relation)
|
||||
.unwrap()
|
||||
.1
|
||||
.center()
|
||||
} else {
|
||||
assert!(scene.routed.edges[&relation].custom_suspended);
|
||||
bend_segment(&scene, relation).1
|
||||
};
|
||||
let start = screen(&canvas, world);
|
||||
motion(&canvas, &mut state, start, start);
|
||||
assert!(matches!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
start
|
||||
),
|
||||
Some(Event::ContextMenu {
|
||||
bend: None,
|
||||
route_segment: None,
|
||||
..
|
||||
})
|
||||
));
|
||||
bend_press(&canvas, &mut state, start);
|
||||
let away = start + Vector::new(30., 20.);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert!(!matches!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
away
|
||||
),
|
||||
Some(Event::SetBends { .. })
|
||||
));
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_right_click_discards_provisional_indexes_before_building_menu() {
|
||||
for click_pin in [true, false] {
|
||||
let (mut f, relation) = bend_fixture(true);
|
||||
let route = &mut f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
|
||||
route.waypoints = vec![Position { x: 650., y: 340. }, Position { x: 650., y: 390. }];
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let edge = &baseline.routed.edges[&relation];
|
||||
let candidates: Vec<_> = edge.points.windows(2).enumerate()
|
||||
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
|
||||
.map(|(i, p)| (i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.)))
|
||||
.filter(|(_, p)| edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.))
|
||||
.filter(|(_, p)| matches!(canvas.hit(&baseline, *p), Some(Hit::Transition(id)) if id == relation))
|
||||
.filter(|(_, p)| !Rectangle::new(point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height)).contains(*p))
|
||||
.collect();
|
||||
let (_, insertion_at) = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(i, p)| routing::pin_insertion_index(edge, *i, position(*p)) == 0)
|
||||
.expect("exposed segment before both authored pins");
|
||||
let menu_segment = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(_, p)| p.distance(insertion_at) > 30.)
|
||||
.expect("a distinct committed segment for the menu control");
|
||||
let start = screen(&canvas, insertion_at);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
let away = start + Vector::new(45., 30.);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
let preview = state.bend_preview.as_ref().unwrap();
|
||||
assert_eq!(preview.waypoints.len(), 3);
|
||||
assert_eq!(
|
||||
&preview.waypoints[1..],
|
||||
original.waypoints.as_slice(),
|
||||
"provisional insertion shifts both stored indices"
|
||||
);
|
||||
|
||||
// With the left gesture still active, moving to the last stored pin also
|
||||
// moves provisional pin0 there. The preview's first hit is therefore
|
||||
// pin0, but a menu built after cancellation must identify stored pin1.
|
||||
let menu_world = if click_pin {
|
||||
point(original.waypoints[1])
|
||||
} else {
|
||||
menu_segment.1
|
||||
};
|
||||
let menu_window = screen(&canvas, menu_world);
|
||||
motion(&canvas, &mut state, menu_window, menu_window);
|
||||
assert!(state.bend_preview.is_some());
|
||||
let menu = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
menu_window,
|
||||
)
|
||||
.unwrap();
|
||||
let Event::ContextMenu {
|
||||
context,
|
||||
object,
|
||||
bend,
|
||||
route_segment,
|
||||
..
|
||||
} = menu
|
||||
else {
|
||||
panic!("expected context menu after cancelling drag")
|
||||
};
|
||||
assert_eq!(context, canvas.context);
|
||||
assert_eq!(object, relation);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
edge.points
|
||||
);
|
||||
if click_pin {
|
||||
assert_eq!(
|
||||
bend,
|
||||
Some(1),
|
||||
"menu index must address the stored route, not the provisional route"
|
||||
);
|
||||
assert!(route_segment.is_none());
|
||||
let remove = Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: bend.unwrap(),
|
||||
};
|
||||
let plan = state_edit::plan(&f.project, context, &remove).unwrap();
|
||||
let after = prepare_command(&f.project, &plan.command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.remove(1);
|
||||
assert_eq!(
|
||||
after, expected,
|
||||
"remove menu action must delete exactly the addressed original pin"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
bend.is_none(),
|
||||
"provisional pin under cursor must not leak into menu"
|
||||
);
|
||||
let (segment, at) = route_segment.expect("committed route segment remains actionable");
|
||||
assert_eq!(segment, menu_segment.0);
|
||||
assert!(point(at).distance(menu_world) < 0.001);
|
||||
}
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
menu_window
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_draw_before_update_excludes_preview_after_camera_or_bounds_change() {
|
||||
for change in 0..4 {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
|
||||
let start = screen(&canvas, point(baseline.routed.edges[&relation].pins[0]));
|
||||
let release = start + Vector::new(45., 30.);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
motion(&canvas, &mut state, release, release);
|
||||
let displayed_preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
|
||||
assert_ne!(
|
||||
displayed_preview.routed.edges[&relation].pins,
|
||||
baseline.routed.edges[&relation].pins
|
||||
);
|
||||
let mut changed_bounds = bounds();
|
||||
match change {
|
||||
0 => canvas.zoom = 2.,
|
||||
1 => canvas.pan.y += 25.,
|
||||
2 => changed_bounds.x += 35.,
|
||||
_ => changed_bounds.width -= 90.,
|
||||
}
|
||||
// No update event between camera/layout change and this draw-time read.
|
||||
let shown = canvas
|
||||
.scene_with_bounds(&state, Some(changed_bounds))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
shown.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins,
|
||||
"stale draw-time pins for case {change}"
|
||||
);
|
||||
assert_eq!(
|
||||
shown.routed.edges[&relation].points, baseline.routed.edges[&relation].points,
|
||||
"stale cached preview for case {change}"
|
||||
);
|
||||
let event = input_at_bounds(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
release,
|
||||
changed_bounds,
|
||||
);
|
||||
assert!(!matches!(event, Some(Event::SetBends { .. })));
|
||||
assert!(state.bend_preview.is_none());
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,12 @@ pub struct State {
|
||||
press: Option<Press>,
|
||||
source: Option<Id>,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
bend: Option<BendGesture>,
|
||||
bend_preview: Option<EdgeRoute>,
|
||||
pan_from: Option<(mouse::Button, Point)>,
|
||||
viewport: Option<Size>,
|
||||
attachment_viewport: Option<(f32, Vector, Rectangle)>,
|
||||
scene: RefCell<Option<CachedScene>>,
|
||||
geometry: canvas::Cache,
|
||||
paint_key: RefCell<Option<PaintKey>>,
|
||||
@@ -76,6 +80,30 @@ enum Press {
|
||||
/// Handles require a drag; an explicitly chosen reconnect tool accepts a click.
|
||||
start: Option<Point>,
|
||||
},
|
||||
Attachment {
|
||||
relation: Id,
|
||||
end: EdgeEnd,
|
||||
placement: Id,
|
||||
start: Point,
|
||||
},
|
||||
Bend {
|
||||
relation: Id,
|
||||
},
|
||||
}
|
||||
|
||||
struct BendGesture {
|
||||
start: Point,
|
||||
route: EdgeRoute,
|
||||
index: usize,
|
||||
baseline: Arc<Scene>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct AttachmentPreview {
|
||||
relation: Id,
|
||||
end: EdgeEnd,
|
||||
placement: Id,
|
||||
attachment: EdgeAttachment,
|
||||
}
|
||||
|
||||
struct Scene {
|
||||
@@ -86,12 +114,16 @@ struct Scene {
|
||||
struct CachedScene {
|
||||
context: Context,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
bend_preview: Option<EdgeRoute>,
|
||||
scene: Arc<Scene>,
|
||||
}
|
||||
#[derive(PartialEq)]
|
||||
struct PaintKey {
|
||||
context: Context,
|
||||
preview: Option<(Id, Position)>,
|
||||
attachment_preview: Option<AttachmentPreview>,
|
||||
bend_preview: Option<EdgeRoute>,
|
||||
selected: Id,
|
||||
zoom: f32,
|
||||
pan: Vector,
|
||||
@@ -104,9 +136,16 @@ enum Hit {
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn cancel_gesture(&mut self) {
|
||||
fn cancel_edit(&mut self) {
|
||||
self.press = None;
|
||||
self.preview = None;
|
||||
self.attachment_preview = None;
|
||||
self.bend = None;
|
||||
self.bend_preview = None;
|
||||
self.attachment_viewport = None;
|
||||
}
|
||||
fn cancel_gesture(&mut self) {
|
||||
self.cancel_edit();
|
||||
self.pan_from = None;
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
@@ -649,20 +688,36 @@ impl StateCanvas<'_> {
|
||||
&& state_edit::validate_context(self.project, self.context, self.context).is_ok()
|
||||
}
|
||||
fn scene(&self, state: &State) -> Option<Arc<Scene>> {
|
||||
self.scene_with_bounds(state, None)
|
||||
}
|
||||
fn scene_with_bounds(&self, state: &State, bounds: Option<Rectangle>) -> Option<Arc<Scene>> {
|
||||
if !self.valid() {
|
||||
return None;
|
||||
}
|
||||
let route_active = self.gesture_active(state) && self.route_viewport_current(state, bounds);
|
||||
let preview = self
|
||||
.gesture_active(state)
|
||||
.then_some(state.preview)
|
||||
.flatten();
|
||||
let attachment_preview = route_active.then_some(state.attachment_preview).flatten();
|
||||
let bend_preview = route_active.then(|| state.bend_preview.clone()).flatten();
|
||||
if let Some(cached) = state.scene.borrow().as_ref()
|
||||
&& cached.context == self.context
|
||||
&& cached.preview == preview
|
||||
&& cached.attachment_preview == attachment_preview
|
||||
&& cached.bend_preview == bend_preview
|
||||
{
|
||||
return Some(cached.scene.clone());
|
||||
}
|
||||
let mut diagram = self.project.diagrams.get(&self.diagram)?.clone();
|
||||
if let Some(route) = &bend_preview {
|
||||
crate::route_edit::with_route(&mut diagram, route.clone());
|
||||
}
|
||||
if let Some(preview) = attachment_preview {
|
||||
let mut route = crate::route_edit::stored_route(&diagram, preview.relation);
|
||||
route.set_attachment(preview.end, Some(preview.attachment));
|
||||
crate::route_edit::with_route(&mut diagram, route);
|
||||
}
|
||||
if let Some((id, at)) = preview
|
||||
&& let Some(node) = diagram.nodes.get(&id)
|
||||
{
|
||||
@@ -687,7 +742,17 @@ impl StateCanvas<'_> {
|
||||
}
|
||||
}
|
||||
let view = projection::project_diagram(self.project, &diagram);
|
||||
let routed = routing::route_diagram(self.project, &diagram, &view);
|
||||
let routed = if let Some(gesture) = state.bend.as_ref().filter(|_| bend_preview.is_some()) {
|
||||
routing::route_edge_preview(
|
||||
self.project,
|
||||
&diagram,
|
||||
&view,
|
||||
&gesture.baseline.routed,
|
||||
gesture.route.edge,
|
||||
)
|
||||
} else {
|
||||
routing::route_diagram(self.project, &diagram, &view)
|
||||
};
|
||||
let symbol_labels = symbol_labels(self.project, &view, &routed);
|
||||
let scene = Arc::new(Scene {
|
||||
view,
|
||||
@@ -697,6 +762,8 @@ impl StateCanvas<'_> {
|
||||
*state.scene.borrow_mut() = Some(CachedScene {
|
||||
context: self.context,
|
||||
preview,
|
||||
attachment_preview,
|
||||
bend_preview,
|
||||
scene: scene.clone(),
|
||||
});
|
||||
Some(scene)
|
||||
@@ -828,6 +895,84 @@ impl StateCanvas<'_> {
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(endpoint, _)| (route.id, endpoint))
|
||||
}
|
||||
fn bend_at(&self, scene: &Scene, world: Point) -> Option<usize> {
|
||||
let edge = scene.routed.edges.get(&self.selected)?;
|
||||
if edge.custom_suspended {
|
||||
return None;
|
||||
}
|
||||
edge.pins
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, pin)| {
|
||||
let distance = world.distance(point(*pin));
|
||||
(distance <= 8. / self.scale()).then_some((index, distance))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
|
||||
.map(|(index, _)| index)
|
||||
}
|
||||
/// Preserve the rendered traversal index; overlapping runs can have the
|
||||
/// same world point but different positions among the authored pins.
|
||||
fn segment_at(&self, scene: &Scene, relation: Id, world: Point) -> Option<(usize, Position)> {
|
||||
let edge = scene.routed.edges.get(&relation)?;
|
||||
if edge.custom_suspended {
|
||||
return None;
|
||||
}
|
||||
edge.points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(index, _)| *index > 0 && index + 2 < edge.points.len())
|
||||
.filter_map(|(index, span)| {
|
||||
let projection = routing::project_onto_polyline(span, position(world))?;
|
||||
(projection.distance <= 7. / self.scale()).then_some((index, projection.distance))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)))
|
||||
.map(|(index, _)| (index, position(world)))
|
||||
}
|
||||
fn begin_bend(
|
||||
&self,
|
||||
state: &mut State,
|
||||
scene: Arc<Scene>,
|
||||
window: Point,
|
||||
route: EdgeRoute,
|
||||
index: usize,
|
||||
bounds: Rectangle,
|
||||
) {
|
||||
state.press = Some(Press::Bend {
|
||||
relation: route.edge,
|
||||
});
|
||||
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
|
||||
state.bend = Some(BendGesture {
|
||||
start: window,
|
||||
route,
|
||||
index,
|
||||
baseline: scene,
|
||||
});
|
||||
state.bend_preview = None;
|
||||
}
|
||||
fn move_bend_pointer(&self, state: &mut State, window: Point, local: Point) {
|
||||
let Some(gesture) = &state.bend else {
|
||||
return;
|
||||
};
|
||||
if window.distance(gesture.start) < 3. {
|
||||
// Also discard a provisional insertion. The exact original route
|
||||
// comes from the model, so an off-grid pin is never quantized here.
|
||||
state.bend_preview = None;
|
||||
return;
|
||||
}
|
||||
let world = self.world(local);
|
||||
if !world.x.is_finite() || !world.y.is_finite() {
|
||||
state.cancel_gesture();
|
||||
return;
|
||||
}
|
||||
let mut changed = gesture.route.clone();
|
||||
changed.waypoints[gesture.index] = Position {
|
||||
x: (world.x / 5.).round() * 5.,
|
||||
y: (world.y / 5.).round() * 5.,
|
||||
};
|
||||
let original =
|
||||
crate::route_edit::stored_route(&self.project.diagrams[&self.diagram], changed.edge);
|
||||
state.bend_preview = (changed != original).then_some(changed);
|
||||
}
|
||||
fn owner_at(&self, scene: &Scene, world: Point, kind: Kind) -> Option<Id> {
|
||||
self.node_at(scene, world)
|
||||
.filter(|n| state_edit::eligible_owner(self.project, self.diagram, n.element, kind))
|
||||
@@ -856,12 +1001,98 @@ impl StateCanvas<'_> {
|
||||
}
|
||||
}
|
||||
fn gesture_active(&self, state: &State) -> bool {
|
||||
self.editable && state.context == Some(self.context) && state.tool == Some(self.tool)
|
||||
self.editable
|
||||
&& state.context == Some(self.context)
|
||||
&& state.tool == Some(self.tool)
|
||||
&& state
|
||||
.bend
|
||||
.as_ref()
|
||||
.is_none_or(|g| g.route.edge == self.selected)
|
||||
}
|
||||
fn route_viewport_current(&self, state: &State, bounds: Option<Rectangle>) -> bool {
|
||||
state
|
||||
.attachment_viewport
|
||||
.is_none_or(|(zoom, pan, captured)| {
|
||||
zoom == self.zoom
|
||||
&& pan == self.pan
|
||||
&& bounds.is_none_or(|current| current == captured)
|
||||
})
|
||||
}
|
||||
fn move_attachment_pointer(&self, state: &mut State, window: Point, local: Point) {
|
||||
let Some(Press::Attachment {
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
start,
|
||||
}) = state.press
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if window.distance(start) < 3. {
|
||||
// Returning to the press restores the exact persisted optional value,
|
||||
// without quantizing an existing attachment or pinning an automatic one.
|
||||
state.attachment_preview = None;
|
||||
return;
|
||||
}
|
||||
let Some(owner) = state_edit::attachment_node(self.project, self.diagram, relation, end)
|
||||
.filter(|owner| owner.id == placement)
|
||||
else {
|
||||
state.cancel_gesture();
|
||||
return;
|
||||
};
|
||||
let diagram = &self.project.diagrams[&self.diagram];
|
||||
let previous = state
|
||||
.attachment_preview
|
||||
.map(|preview| preview.attachment.side)
|
||||
.or_else(|| {
|
||||
diagram
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.edge == relation)
|
||||
.and_then(|route| route.attachment(end))
|
||||
.map(|attachment| attachment.side)
|
||||
});
|
||||
let world = self.world(local);
|
||||
if !world.x.is_finite() || !world.y.is_finite() {
|
||||
state.cancel_gesture();
|
||||
return;
|
||||
}
|
||||
let attachment =
|
||||
routing::attachment_from_point(&owner, position(world), previous, 6. / self.scale());
|
||||
state.attachment_preview = Some(AttachmentPreview {
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
attachment,
|
||||
});
|
||||
}
|
||||
fn guidance(&self, state: &State, scene: &Scene, world: Option<Point>) -> String {
|
||||
if !self.editable {
|
||||
return "Pending edits · apply or cancel them before editing the diagram".into();
|
||||
}
|
||||
if state.bend.is_some()
|
||||
&& self.gesture_active(state)
|
||||
&& self.route_viewport_current(state, None)
|
||||
{
|
||||
return "Move bend · release to keep · Esc cancels".into();
|
||||
}
|
||||
if self.tool == Tool::Select
|
||||
&& state.press.is_none()
|
||||
&& scene
|
||||
.routed
|
||||
.edges
|
||||
.get(&self.selected)
|
||||
.is_some_and(|edge| !edge.custom_suspended)
|
||||
{
|
||||
return "Drag line to add a bend · squares move bends · right-click a square to remove"
|
||||
.into();
|
||||
}
|
||||
if let Tool::AdjustAttachment { relation, end } = self.tool {
|
||||
return format!(
|
||||
"Adjust {end:?} attachment of {} · drag that tip around its existing vertex boundary · Esc cancels · relation unchanged",
|
||||
self.project.named(relation)
|
||||
);
|
||||
}
|
||||
let hovered = world.and_then(|p| self.vertex_at(scene, p));
|
||||
let press = self.gesture_active(state).then_some(state.press).flatten();
|
||||
let source = self.gesture_active(state).then_some(state.source).flatten();
|
||||
@@ -950,10 +1181,24 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
state.tool = Some(self.tool);
|
||||
}
|
||||
if !self.editable {
|
||||
state.press = None;
|
||||
state.preview = None;
|
||||
// Pending edits block model changes, but read-only panning remains
|
||||
// available and must survive successive pointer events.
|
||||
state.cancel_edit();
|
||||
state.source = None;
|
||||
}
|
||||
if state
|
||||
.bend
|
||||
.as_ref()
|
||||
.is_some_and(|g| g.route.edge != self.selected)
|
||||
{
|
||||
state.cancel_gesture();
|
||||
}
|
||||
if state
|
||||
.attachment_viewport
|
||||
.is_some_and(|view| view != (self.zoom, self.pan, bounds))
|
||||
{
|
||||
state.cancel_gesture();
|
||||
}
|
||||
// Winit positions are ordered events. The cursor supplied by Iced is the
|
||||
// final cursor for an entire batch and cannot reinterpret earlier clicks.
|
||||
let cursor = match event {
|
||||
@@ -963,6 +1208,9 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorLeft) => {
|
||||
state.pointer = None;
|
||||
if state.bend.is_some() {
|
||||
state.cancel_gesture();
|
||||
}
|
||||
cursor
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
@@ -981,6 +1229,11 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
state.reset();
|
||||
return None;
|
||||
}
|
||||
canvas::Event::Window(iced::window::Event::Resized(_)) => {
|
||||
state.cancel_gesture();
|
||||
state.viewport = None;
|
||||
return None;
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
let window = cursor.position();
|
||||
@@ -1053,10 +1306,23 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
}
|
||||
if *button == mouse::Button::Right {
|
||||
state.reset();
|
||||
let (object, placement) = match self.hit(&scene, world) {
|
||||
Some(Hit::Node { element, placement }) => (element, Some(placement)),
|
||||
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
|
||||
None => (self.diagram, None),
|
||||
// A canceled provisional pin shifts indices in its preview.
|
||||
// Menu commands must target the committed route instead.
|
||||
let scene = self.scene(state)?;
|
||||
let bend = self.bend_at(&scene, world);
|
||||
let (object, placement) = if bend.is_some() {
|
||||
(self.selected, None)
|
||||
} else {
|
||||
match self.hit(&scene, world) {
|
||||
Some(Hit::Node { element, placement }) => (element, Some(placement)),
|
||||
Some(Hit::Transition(id) | Hit::StateAction(id)) => (id, None),
|
||||
None => (self.diagram, None),
|
||||
}
|
||||
};
|
||||
let route_segment = if bend.is_none() {
|
||||
self.segment_at(&scene, object, world)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Event::ContextMenu {
|
||||
@@ -1064,6 +1330,8 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
object,
|
||||
placement,
|
||||
anchor: window,
|
||||
bend,
|
||||
route_segment,
|
||||
})
|
||||
.and_capture(),
|
||||
);
|
||||
@@ -1081,6 +1349,14 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
});
|
||||
return Some(Action::capture());
|
||||
}
|
||||
if let Some(index) = self.bend_at(&scene, world) {
|
||||
let route = crate::route_edit::stored_route(
|
||||
&self.project.diagrams[&self.diagram],
|
||||
self.selected,
|
||||
);
|
||||
self.begin_bend(state, scene.clone(), window, route, index, bounds);
|
||||
return Some(Action::capture());
|
||||
}
|
||||
match self.hit(&scene, world) {
|
||||
Some(Hit::Node { placement, element }) => {
|
||||
let original = self
|
||||
@@ -1101,6 +1377,36 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
)
|
||||
}
|
||||
Some(Hit::Transition(id) | Hit::StateAction(id)) => {
|
||||
if id == self.selected
|
||||
&& let Some(edge) = scene.routed.edges.get(&id)
|
||||
&& !Rectangle::new(
|
||||
point(edge.label.bounds.position),
|
||||
Size::new(
|
||||
edge.label.bounds.size.width,
|
||||
edge.label.bounds.size.height,
|
||||
),
|
||||
)
|
||||
.contains(world)
|
||||
&& let Some((segment, at)) = self.segment_at(&scene, id, world)
|
||||
{
|
||||
let mut route = crate::route_edit::stored_route(
|
||||
&self.project.diagrams[&self.diagram],
|
||||
id,
|
||||
);
|
||||
if let Some(index) = crate::route_edit::insert_pin_on_segment(
|
||||
&mut route, edge, segment, at,
|
||||
) {
|
||||
self.begin_bend(
|
||||
state,
|
||||
scene.clone(),
|
||||
window,
|
||||
route,
|
||||
index,
|
||||
bounds,
|
||||
);
|
||||
return Some(Action::capture());
|
||||
}
|
||||
}
|
||||
Some(Action::publish(Event::Select(self.context, id)).and_capture())
|
||||
}
|
||||
None => {
|
||||
@@ -1142,6 +1448,26 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
});
|
||||
Some(Action::capture())
|
||||
}
|
||||
Tool::AdjustAttachment { relation, end } => {
|
||||
let owner =
|
||||
state_edit::attachment_node(self.project, self.diagram, relation, end)?;
|
||||
let route = scene.routed.edges.get(&relation)?;
|
||||
let tip = match end {
|
||||
EdgeEnd::Source => route.points.first(),
|
||||
EdgeEnd::Target => route.points.last(),
|
||||
}?;
|
||||
if world.distance(point(*tip)) > 10. / self.scale() {
|
||||
return None;
|
||||
}
|
||||
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
|
||||
state.press = Some(Press::Attachment {
|
||||
relation,
|
||||
end,
|
||||
placement: owner.id,
|
||||
start: window,
|
||||
});
|
||||
Some(Action::capture())
|
||||
}
|
||||
}
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
||||
@@ -1156,6 +1482,10 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
}
|
||||
}
|
||||
self.move_pointer(state, window);
|
||||
if let Some(local) = local {
|
||||
self.move_attachment_pointer(state, window, local);
|
||||
self.move_bend_pointer(state, window, local);
|
||||
}
|
||||
Some(Action::request_redraw())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
|
||||
@@ -1176,16 +1506,49 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
}
|
||||
if let Some(window) = window {
|
||||
self.move_pointer(state, window);
|
||||
if let Some(local) = local {
|
||||
self.move_attachment_pointer(state, window, local);
|
||||
self.move_bend_pointer(state, window, local);
|
||||
}
|
||||
}
|
||||
let press = state.press.take()?;
|
||||
state.attachment_viewport = None;
|
||||
let preview = state.preview.take();
|
||||
let attachment_preview = state.attachment_preview.take();
|
||||
let bend_preview = state.bend_preview.take();
|
||||
state.bend = None;
|
||||
let world = local.map(|p| self.world(p));
|
||||
let event = match press {
|
||||
Press::Bend { relation } if inside => bend_preview
|
||||
.filter(|route| route.edge == relation)
|
||||
.map(|route| Event::SetBends {
|
||||
context: self.context,
|
||||
relation,
|
||||
waypoints: route.waypoints,
|
||||
}),
|
||||
Press::Move { node, .. } => preview.map(|(_, position)| Event::Move {
|
||||
context: self.context,
|
||||
node,
|
||||
position,
|
||||
}),
|
||||
Press::Attachment {
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
..
|
||||
} if inside => attachment_preview
|
||||
.filter(|preview| {
|
||||
preview.relation == relation
|
||||
&& preview.end == end
|
||||
&& preview.placement == placement
|
||||
})
|
||||
.map(|preview| Event::SetAttachment {
|
||||
context: self.context,
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
attachment: Some(preview.attachment),
|
||||
}),
|
||||
Press::Create { owner, kind, start }
|
||||
if inside
|
||||
&& window?.distance(start) < 3.
|
||||
@@ -1273,15 +1636,19 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
let Some(scene) = self.scene(state) else {
|
||||
let Some(scene) = self.scene_with_bounds(state, Some(bounds)) else {
|
||||
return vec![];
|
||||
};
|
||||
let route_active =
|
||||
self.gesture_active(state) && self.route_viewport_current(state, Some(bounds));
|
||||
let key = PaintKey {
|
||||
context: self.context,
|
||||
preview: self
|
||||
.gesture_active(state)
|
||||
.then_some(state.preview)
|
||||
.flatten(),
|
||||
attachment_preview: route_active.then_some(state.attachment_preview).flatten(),
|
||||
bend_preview: route_active.then(|| state.bend_preview.clone()).flatten(),
|
||||
selected: self.selected,
|
||||
zoom: self.scale(),
|
||||
pan: self.pan,
|
||||
@@ -1361,6 +1728,22 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
style::MUTED,
|
||||
);
|
||||
if active {
|
||||
if !edge.custom_suspended && self.tool == Tool::Select {
|
||||
for pin in &edge.pins {
|
||||
let radius = 4. / self.scale();
|
||||
let handle = Path::rectangle(
|
||||
point(*pin) - Vector::new(radius, radius),
|
||||
Size::new(radius * 2., radius * 2.),
|
||||
);
|
||||
frame.fill(&handle, Color::WHITE);
|
||||
frame.stroke(
|
||||
&handle,
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(1.6 / self.scale()),
|
||||
);
|
||||
}
|
||||
}
|
||||
for end in [edge.points.first(), edge.points.last()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
@@ -1391,6 +1774,20 @@ impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
.with_width(2.5 / self.scale()),
|
||||
);
|
||||
}
|
||||
if let Tool::AdjustAttachment { relation, end } = self.tool
|
||||
&& let Some(route) = scene.routed.edges.get(&relation)
|
||||
&& let Some(tip) = match end {
|
||||
EdgeEnd::Source => route.points.first(),
|
||||
EdgeEnd::Target => route.points.last(),
|
||||
}
|
||||
{
|
||||
frame.stroke(
|
||||
&Path::circle(point(*tip), 9. / self.scale()),
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(2. / self.scale()),
|
||||
);
|
||||
}
|
||||
if let Some(at) = world
|
||||
&& self.editable
|
||||
{
|
||||
@@ -1761,6 +2158,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
|
||||
include!("state_bend_tests.rs");
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
@@ -2216,7 +2615,7 @@ mod tests {
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(matches!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right), at),
|
||||
Some(Event::ContextMenu { context, object, placement: Some(node), anchor })
|
||||
Some(Event::ContextMenu { context, object, placement: Some(node), anchor, .. })
|
||||
if context == canvas.context && object == f.b
|
||||
&& node == placement(&f, f.b) && anchor == at));
|
||||
let background = Point::new(
|
||||
@@ -2226,7 +2625,7 @@ mod tests {
|
||||
motion(&canvas, &mut state, background, background);
|
||||
assert!(matches!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right), background),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor })
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, .. })
|
||||
if context == canvas.context && object == f.diagram && anchor == background));
|
||||
}
|
||||
|
||||
@@ -2483,6 +2882,242 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_attachment_drag_is_view_only_and_one_source_history_operation() {
|
||||
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let mut f = fixture();
|
||||
let relation =
|
||||
f.project
|
||||
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
||||
let second = state_edit::create_view(&f.project, f.machine).unwrap();
|
||||
let other_id = second.diagram;
|
||||
f.project = prepare_command(&f.project, &second.command)
|
||||
.unwrap()
|
||||
.project;
|
||||
let other = f.project.diagrams[&other_id].clone();
|
||||
let mut canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let route = &scene.routed.edges[&relation];
|
||||
let tip = match end {
|
||||
EdgeEnd::Source => route.points.first(),
|
||||
EdgeEnd::Target => route.points.last(),
|
||||
}
|
||||
.unwrap();
|
||||
let handle = screen(&canvas, point(*tip));
|
||||
motion(&canvas, &mut state, handle, handle);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
handle,
|
||||
);
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
handle
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
state.attachment_preview.is_none(),
|
||||
"A click must not materialize an automatic endpoint"
|
||||
);
|
||||
let owner =
|
||||
state_edit::attachment_node(&f.project, f.diagram, relation, end).unwrap();
|
||||
let destination = screen(
|
||||
&canvas,
|
||||
Point::new(owner.position.x + owner.size.width * 0.25, owner.position.y),
|
||||
);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
handle,
|
||||
);
|
||||
motion(&canvas, &mut state, destination, destination);
|
||||
assert!(state.attachment_preview.is_some());
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
let event = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
destination,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(event, Event::SetAttachment { end: actual, .. } if actual == end));
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let mut document =
|
||||
Document::from_project(f.project.clone(), Default::default()).unwrap();
|
||||
let source = document.bundle().clone();
|
||||
document.execute(plan.command).unwrap();
|
||||
assert_eq!(document.project().relations, f.project.relations);
|
||||
assert_eq!(document.project().diagrams[&other_id], other);
|
||||
let route = &document.project().diagrams[&f.diagram].routes[0];
|
||||
assert_eq!(
|
||||
route.attachment(end),
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
let edited = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.bundle(), &source);
|
||||
assert!(!document.undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &edited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_return_to_press_restores_exact_automatic_or_fractional_original() {
|
||||
for original in [
|
||||
None,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.234567,
|
||||
}),
|
||||
] {
|
||||
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
||||
let mut f = fixture();
|
||||
let relation =
|
||||
f.project
|
||||
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
||||
if original.is_some() {
|
||||
let mut route =
|
||||
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation);
|
||||
route.set_attachment(end, original);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route);
|
||||
}
|
||||
let canvas = canvas(&f, Tool::AdjustAttachment { relation, end }, 1.);
|
||||
let mut state = State::default();
|
||||
let before = canvas.scene(&state).unwrap();
|
||||
let edge = &before.routed.edges[&relation];
|
||||
let tip = match end {
|
||||
EdgeEnd::Source => edge.points.first(),
|
||||
EdgeEnd::Target => edge.points.last(),
|
||||
}
|
||||
.unwrap();
|
||||
let start = screen(&canvas, point(*tip));
|
||||
let away = start + Vector::new(80., 40.);
|
||||
motion(&canvas, &mut state, start, start);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
start,
|
||||
);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(state.attachment_preview.is_some());
|
||||
motion(&canvas, &mut state, start, start);
|
||||
assert!(state.attachment_preview.is_none());
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
edge.points
|
||||
);
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
start
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
crate::route_edit::stored_route(&f.project.diagrams[&f.diagram], relation)
|
||||
.attachment(end),
|
||||
original
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_drag_cancels_on_camera_context_tool_pending_and_focus_changes() {
|
||||
let mut f = fixture();
|
||||
let relation = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
||||
for cancel in 0..9 {
|
||||
let mut canvas = canvas(
|
||||
&f,
|
||||
Tool::AdjustAttachment {
|
||||
relation,
|
||||
end: EdgeEnd::Source,
|
||||
},
|
||||
1.,
|
||||
);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let handle = screen(&canvas, point(scene.routed.edges[&relation].points[0]));
|
||||
let destination = handle + Vector::new(40., 30.);
|
||||
motion(&canvas, &mut state, handle, handle);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
handle,
|
||||
);
|
||||
motion(&canvas, &mut state, destination, destination);
|
||||
assert!(state.attachment_preview.is_some());
|
||||
match cancel {
|
||||
0 => canvas.zoom = 2.,
|
||||
1 => canvas.pan.x += 10.,
|
||||
2 => canvas.context.epoch += 1,
|
||||
3 => canvas.tool = Tool::Select,
|
||||
4 => canvas.editable = false,
|
||||
5 | 6 => {
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(if cancel == 5 {
|
||||
mouse::Button::Right
|
||||
} else {
|
||||
mouse::Button::Middle
|
||||
}),
|
||||
destination,
|
||||
);
|
||||
}
|
||||
7 => {
|
||||
canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(destination),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
motion(&canvas, &mut state, Point::new(-100., -100.), destination);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
destination
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.attachment_preview.is_none());
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_endpoint_reconnect_preserves_identity_and_requires_handle_motion() {
|
||||
let mut f = fixture();
|
||||
|
||||
@@ -20,6 +20,7 @@ pub enum Tool {
|
||||
Region,
|
||||
Transition,
|
||||
Reconnect { relation: Id, endpoint: Endpoint },
|
||||
AdjustAttachment { relation: Id, end: EdgeEnd },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -38,6 +39,8 @@ pub enum Event {
|
||||
object: Id,
|
||||
placement: Option<Id>,
|
||||
anchor: iced::Point,
|
||||
bend: Option<usize>,
|
||||
route_segment: Option<(usize, Position)>,
|
||||
},
|
||||
CreateAt {
|
||||
context: Context,
|
||||
@@ -66,6 +69,32 @@ pub enum Event {
|
||||
endpoint: Endpoint,
|
||||
vertex: Id,
|
||||
},
|
||||
SetAttachment {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
end: EdgeEnd,
|
||||
placement: Id,
|
||||
attachment: Option<EdgeAttachment>,
|
||||
},
|
||||
SetBends {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
waypoints: Vec<Position>,
|
||||
},
|
||||
AddBend {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
segment: Option<(usize, Position)>,
|
||||
},
|
||||
RemoveBend {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
index: usize,
|
||||
},
|
||||
ClearBends {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
@@ -84,7 +113,12 @@ impl Event {
|
||||
| Self::Place { context: c, .. }
|
||||
| Self::Move { context: c, .. }
|
||||
| Self::Connect { context: c, .. }
|
||||
| Self::Reconnect { context: c, .. } => Some(*c),
|
||||
| Self::Reconnect { context: c, .. }
|
||||
| Self::SetAttachment { context: c, .. }
|
||||
| Self::SetBends { context: c, .. }
|
||||
| Self::AddBend { context: c, .. }
|
||||
| Self::RemoveBend { context: c, .. }
|
||||
| Self::ClearBends { context: c, .. } => Some(*c),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +283,54 @@ pub fn can_reconnect(
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Only the actual visible transition owner can receive a visual attachment.
|
||||
/// Folded promotion, semantic ports and internal-transition rows are excluded.
|
||||
pub fn attachment_node(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
relation: Id,
|
||||
end: EdgeEnd,
|
||||
) -> Option<projection::ProjectedNode> {
|
||||
machine(project, diagram).ok()?;
|
||||
let relation_record = project.relations.get(&relation)?;
|
||||
if relation_record.kind() != RelationKind::StateTransition
|
||||
|| oa_core::routing::semantic_port_endpoint(project, relation, end).is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let view = projection::project_view(project, diagram);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == relation)?;
|
||||
let stored = project.diagrams.get(&diagram)?;
|
||||
if projection::edge_endpoint_placements(project, stored, relation)?
|
||||
!= (edge.source, edge.target)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let owner = match end {
|
||||
EdgeEnd::Source => edge.source,
|
||||
EdgeEnd::Target => edge.target,
|
||||
};
|
||||
view.nodes.iter().find(|node| node.id == owner).cloned()
|
||||
}
|
||||
|
||||
/// Actual visible State transition geometry, never an internal row or folded proxy.
|
||||
pub fn bend_route(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
relation: Id,
|
||||
) -> Option<oa_core::routing::RoutedEdge> {
|
||||
machine(project, diagram).ok()?;
|
||||
if project.relations.get(&relation)?.kind() != RelationKind::StateTransition {
|
||||
return None;
|
||||
}
|
||||
let stored = project.diagrams.get(&diagram)?;
|
||||
let view = projection::project_diagram(project, stored);
|
||||
let geometry = oa_core::routing::route_diagram(project, stored, &view)
|
||||
.edges
|
||||
.remove(&relation)?;
|
||||
(!geometry.custom_suspended).then_some(geometry)
|
||||
}
|
||||
|
||||
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
|
||||
prepare_command(project, &plan.command)?;
|
||||
Ok(plan)
|
||||
@@ -492,6 +574,82 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
|
||||
relation,
|
||||
None,
|
||||
),
|
||||
Event::SetAttachment {
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
attachment,
|
||||
..
|
||||
} => {
|
||||
attachment_node(project, diagram, relation, end)
|
||||
.filter(|owner| owner.id == placement)
|
||||
.ok_or_else(|| {
|
||||
error("Choose the actual visible transition endpoint in this view.")
|
||||
})?;
|
||||
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
|
||||
route.set_attachment(end, attachment);
|
||||
(Command::SetEdgeRoute { diagram, route }, relation, None)
|
||||
}
|
||||
Event::SetBends { relation, .. }
|
||||
| Event::AddBend { relation, .. }
|
||||
| Event::RemoveBend { relation, .. }
|
||||
| Event::ClearBends { relation, .. } => {
|
||||
let geometry = bend_route(project, diagram, relation)
|
||||
.ok_or_else(|| error("Choose an actual visible transition route in this view."))?;
|
||||
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
|
||||
match event {
|
||||
Event::SetBends { waypoints, .. } => {
|
||||
if waypoints.len() > 128 {
|
||||
return Err(error("A route supports at most 128 bends."));
|
||||
}
|
||||
for &point in waypoints {
|
||||
finite(point)?;
|
||||
}
|
||||
route.waypoints = waypoints.clone();
|
||||
}
|
||||
Event::AddBend { segment, .. } => {
|
||||
let (segment, point) = segment
|
||||
.or_else(|| {
|
||||
geometry
|
||||
.points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(index, _)| {
|
||||
*index > 0 && index + 2 < geometry.points.len()
|
||||
})
|
||||
.max_by(|(_, a), (_, b)| {
|
||||
let length = |s: &[Position]| {
|
||||
(s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs()
|
||||
};
|
||||
length(a).total_cmp(&length(b))
|
||||
})
|
||||
.map(|(index, span)| {
|
||||
(
|
||||
index,
|
||||
Position {
|
||||
x: (span[0].x + span[1].x) / 2.,
|
||||
y: (span[0].y + span[1].y) / 2.,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| error("This route has no interior segment for a bend."))?;
|
||||
crate::route_edit::insert_pin_on_segment(&mut route, &geometry, segment, point)
|
||||
.ok_or_else(|| {
|
||||
error("The bend segment is stale, invalid, or at capacity.")
|
||||
})?;
|
||||
}
|
||||
Event::RemoveBend { index, .. } => {
|
||||
if *index >= route.waypoints.len() {
|
||||
return Err(error("This bend no longer exists."));
|
||||
}
|
||||
route.waypoints.remove(*index);
|
||||
}
|
||||
Event::ClearBends { .. } => route.waypoints.clear(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
(Command::SetEdgeRoute { diagram, route }, relation, None)
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
"Handle this navigation or tool request without a model command.",
|
||||
@@ -580,6 +738,85 @@ mod tests {
|
||||
plan.diagram
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_reset_preserves_other_end_pins_label_and_rejects_ineligible_owners() {
|
||||
let mut f = fixture();
|
||||
let diagram = create(&mut f);
|
||||
let c = context(diagram);
|
||||
let edge = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Leave work",
|
||||
f.leaf,
|
||||
f.target,
|
||||
);
|
||||
let attachment = EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.3,
|
||||
};
|
||||
let mut route = crate::route_edit::stored_route(&f.project.diagrams[&diagram], edge);
|
||||
route.source_attachment = Some(attachment);
|
||||
route.target_attachment = Some(attachment);
|
||||
route.waypoints.push(Position { x: 700., y: 700. });
|
||||
// Retain the exact label value alongside the other route components.
|
||||
route.label = Some(EdgeLabel {
|
||||
fraction: 0.5,
|
||||
offset: Position { x: 10., y: 20. },
|
||||
});
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route.clone());
|
||||
let owner = attachment_node(&f.project, diagram, edge, EdgeEnd::Source).unwrap();
|
||||
let event = Event::SetAttachment {
|
||||
context: c,
|
||||
relation: edge,
|
||||
end: EdgeEnd::Source,
|
||||
placement: owner.id,
|
||||
attachment: None,
|
||||
};
|
||||
let accepted = prepare_command(&f.project, &plan(&f.project, c, &event).unwrap().command)
|
||||
.unwrap()
|
||||
.project;
|
||||
route.source_attachment = None;
|
||||
assert_eq!(accepted.diagrams[&diagram].routes, vec![route]);
|
||||
assert_eq!(accepted.relations, f.project.relations);
|
||||
let wrong = Event::SetAttachment {
|
||||
context: c,
|
||||
relation: edge,
|
||||
end: EdgeEnd::Source,
|
||||
placement: node(&f.project, diagram, f.target),
|
||||
attachment: Some(attachment),
|
||||
};
|
||||
assert!(plan(&f.project, c, &wrong).is_err());
|
||||
let composite = node(&f.project, diagram, f.composite);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&composite)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert!(attachment_node(&f.project, diagram, edge, EdgeEnd::Source).is_none());
|
||||
assert!(plan(&f.project, c, &event).is_err());
|
||||
let internal = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Internal",
|
||||
f.target,
|
||||
f.target,
|
||||
);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut f.project.relations.get_mut(&internal).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Source).is_none());
|
||||
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_initializes_only_its_machine_in_one_source_and_model_history_entry() {
|
||||
let f = fixture();
|
||||
@@ -763,6 +1000,8 @@ mod tests {
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![pin],
|
||||
label: None,
|
||||
});
|
||||
|
||||
@@ -161,6 +161,53 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
);
|
||||
entries.push(item("Edit transition", edit, true));
|
||||
if let Some(context) = context {
|
||||
let bend_geometry = state_edit::bend_route(project, context.diagram, id);
|
||||
let bend_count = bend_geometry.as_ref().map_or(0, |route| route.pins.len());
|
||||
let can_add = bend_geometry.as_ref().is_some_and(|route| {
|
||||
bend_count < 128
|
||||
&& request.route_segment.map_or_else(
|
||||
|| route.points.len() >= 4,
|
||||
|(index, point)| {
|
||||
index
|
||||
.checked_add(2)
|
||||
.is_some_and(|end| end <= route.points.len())
|
||||
&& point.x.is_finite()
|
||||
&& point.y.is_finite()
|
||||
},
|
||||
)
|
||||
});
|
||||
entries.push(item(
|
||||
if request.route_segment.is_some() {
|
||||
"Add bend here"
|
||||
} else {
|
||||
"Add bend"
|
||||
},
|
||||
diagram_event(Event::AddBend {
|
||||
context,
|
||||
relation: id,
|
||||
segment: request.route_segment,
|
||||
}),
|
||||
can_add,
|
||||
));
|
||||
if let Some(index) = request.bend {
|
||||
entries.push(item(
|
||||
"Remove bend",
|
||||
diagram_event(Event::RemoveBend {
|
||||
context,
|
||||
relation: id,
|
||||
index,
|
||||
}),
|
||||
bend_geometry.is_some() && index < bend_count,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Clear bends",
|
||||
diagram_event(Event::ClearBends {
|
||||
context,
|
||||
relation: id,
|
||||
}),
|
||||
bend_geometry.is_some() && bend_count > 0,
|
||||
));
|
||||
let visible = projection::project_view(project, context.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
@@ -178,6 +225,44 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
visible,
|
||||
));
|
||||
}
|
||||
for (caption, reset, end) in [
|
||||
(
|
||||
"Adjust source attachment",
|
||||
"Reset source attachment",
|
||||
EdgeEnd::Source,
|
||||
),
|
||||
(
|
||||
"Adjust target attachment",
|
||||
"Reset target attachment",
|
||||
EdgeEnd::Target,
|
||||
),
|
||||
] {
|
||||
let owner = state_edit::attachment_node(project, context.diagram, id, end);
|
||||
entries.push(item(
|
||||
caption,
|
||||
diagram_event(Event::Choose(Tool::AdjustAttachment { relation: id, end })),
|
||||
owner.is_some(),
|
||||
));
|
||||
if let Some(owner) = owner {
|
||||
let authored = project.diagrams[&context.diagram]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.edge == id)
|
||||
.and_then(|route| route.attachment(end))
|
||||
.is_some();
|
||||
entries.push(item(
|
||||
reset,
|
||||
diagram_event(Event::SetAttachment {
|
||||
context,
|
||||
relation: id,
|
||||
end,
|
||||
placement: owner.id,
|
||||
attachment: None,
|
||||
}),
|
||||
authored,
|
||||
));
|
||||
}
|
||||
}
|
||||
if !visible {
|
||||
entries.push(Entry::title(
|
||||
"Use transition details for hidden or internal endpoints",
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
// Included in connection_edit::tests: exercise the real application event path.
|
||||
use oa_core::projection::project_view;
|
||||
|
||||
fn allocation_fixture(layer: Layer, projection: ProjectionKind) -> (App, Id, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let (performer_kind, work_kind) = match layer {
|
||||
Layer::Oa => (Kind::Entity, Kind::Activity),
|
||||
Layer::Sa => (Kind::System, Kind::Function),
|
||||
};
|
||||
let performer = project.add(performer_kind, "Assigned performer", 20., 20.);
|
||||
let work = project.add(work_kind, "Existing leaf work", 420., 20.);
|
||||
let diagram = project.ensure_diagram(layer);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = projection;
|
||||
let reference_id = Id::new_v4();
|
||||
let mut reference = project.diagrams[&diagram].clone();
|
||||
reference.id = reference_id;
|
||||
reference.alias = project.fresh_alias("Reference view", "diagram", reference_id);
|
||||
reference.name = "Reference view".into();
|
||||
reference.nodes.clear();
|
||||
project.diagrams.insert(reference_id, reference);
|
||||
project.place(reference_id, performer, Position { x: 80., y: 100. });
|
||||
project.place(reference_id, work, Position { x: 500., y: 300. });
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: app.project().diagrams[&diagram].name.clone(),
|
||||
}));
|
||||
(app, performer, work, diagram)
|
||||
}
|
||||
|
||||
fn start_allocation(app: &mut App, performer: Id) -> Context {
|
||||
send(app, Event::Choose(Some(Tool::Allocation)));
|
||||
let context = app.route_context().unwrap();
|
||||
assert!(
|
||||
app.connection
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidates
|
||||
.contains(&performer)
|
||||
);
|
||||
send(app, Event::Endpoint(context, performer));
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(performer));
|
||||
context
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_projection_eligibility_requires_both_endpoint_categories() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for projection in [
|
||||
ProjectionKind::Overview,
|
||||
ProjectionKind::Decomposition,
|
||||
ProjectionKind::Traceability,
|
||||
ProjectionKind::Allocation,
|
||||
] {
|
||||
assert!(Tool::Allocation.available_in(layer, projection));
|
||||
}
|
||||
for projection in [
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
ProjectionKind::Capabilities,
|
||||
ProjectionKind::StateMachine {
|
||||
machine: Id::new_v4(),
|
||||
},
|
||||
] {
|
||||
assert!(!Tool::Allocation.available_in(layer, projection));
|
||||
}
|
||||
for projection in [
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
ProjectionKind::Capabilities,
|
||||
] {
|
||||
let (mut app, _, _, _) = allocation_fixture(layer, projection);
|
||||
let before = app.document.bundle().clone();
|
||||
send(&mut app, Event::Choose(Some(Tool::Allocation)));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
// The new predicate retains the four established exchange-tool policies.
|
||||
assert!(Tool::Interaction.available_in(Layer::Oa, ProjectionKind::FunctionalDataflow));
|
||||
assert!(Tool::FunctionalExchange.available_in(Layer::Sa, ProjectionKind::FunctionalDataflow));
|
||||
assert!(Tool::CommunicationMean.available_in(Layer::Oa, ProjectionKind::Capabilities));
|
||||
assert!(Tool::BoundaryExchange.available_in(Layer::Sa, ProjectionKind::Capabilities));
|
||||
assert!(!Tool::FunctionalExchange.available_in(Layer::Oa, ProjectionKind::Overview));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_canvas_commits_only_one_relation_with_exact_source_undo_redo() {
|
||||
use oa_core::source_project::{ProjectLimits, compile_bundle};
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for projection in [
|
||||
ProjectionKind::Overview,
|
||||
ProjectionKind::Decomposition,
|
||||
ProjectionKind::Traceability,
|
||||
ProjectionKind::Allocation,
|
||||
] {
|
||||
let (mut app, performer, work, diagram) = allocation_fixture(layer, projection);
|
||||
let original = app.project().clone();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let context = start_allocation(&mut app, performer);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
send(&mut app, Event::Endpoint(context, work));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let relation = &app.project().relations[&app.selected];
|
||||
let name = "Allocation: Assigned performer → Existing leaf work";
|
||||
let expected_relation = Relation {
|
||||
id: relation.id,
|
||||
alias: original.fresh_alias(name, "relation", relation.id),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::Allocation, performer, work),
|
||||
};
|
||||
let relation_id = relation.id;
|
||||
let mut expected = original.clone();
|
||||
expected.relations.insert(relation_id, expected_relation);
|
||||
assert_eq!(app.project(), &expected, "Only the new relation may change");
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert!(
|
||||
app.status
|
||||
.contains("Allocated Existing leaf work to Assigned performer")
|
||||
);
|
||||
let projected = project_view(app.project(), diagram);
|
||||
if projection == ProjectionKind::Allocation {
|
||||
assert!(!projected.edges.iter().any(|edge| edge.id == relation_id));
|
||||
let owner = projected
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.element == performer)
|
||||
.unwrap();
|
||||
let child = projected
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.element == work)
|
||||
.unwrap();
|
||||
assert_eq!(child.visual_parent, Some(owner.id));
|
||||
} else {
|
||||
assert!(projected.edges.iter().any(|edge| edge.id == relation_id));
|
||||
}
|
||||
let after = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
compile_bundle(&after, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
send(&mut app, Event::Endpoint(context, work));
|
||||
assert_eq!(
|
||||
app.document.revision(),
|
||||
revision + 1,
|
||||
"Repeated release is not another transaction"
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &original);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_candidates_reject_nonleaf_occupied_cross_layer_port_and_hidden_work() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (base, performer, work, diagram) =
|
||||
allocation_fixture(layer, ProjectionKind::Traceability);
|
||||
let mut project = base.project().clone();
|
||||
let work_kind = project.elements[&work].kind();
|
||||
let composite = project
|
||||
.add_element(work_kind, layer, "Composite work", None)
|
||||
.unwrap();
|
||||
project
|
||||
.add_element(work_kind, layer, "Child work", Some(composite))
|
||||
.unwrap();
|
||||
let occupied = project
|
||||
.add_element(work_kind, layer, "Occupied work", None)
|
||||
.unwrap();
|
||||
let allocation = Tool::Allocation
|
||||
.command(&project, performer, occupied)
|
||||
.unwrap();
|
||||
project = prepare_command(&project, &allocation).unwrap().project;
|
||||
let hidden = project
|
||||
.add_element(work_kind, layer, "Unplaced work", None)
|
||||
.unwrap();
|
||||
let other_layer = if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let other_kind = if other_layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let foreign = project
|
||||
.add_element(other_kind, other_layer, "Other perspective", None)
|
||||
.unwrap();
|
||||
let foreign_performer_kind = if other_layer == Layer::Oa {
|
||||
Kind::Actor
|
||||
} else {
|
||||
Kind::SystemActor
|
||||
};
|
||||
let foreign_performer = project
|
||||
.add_element(foreign_performer_kind, other_layer, "Other performer", None)
|
||||
.unwrap();
|
||||
for (index, id) in [composite, occupied, foreign, foreign_performer]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
project.place(
|
||||
diagram,
|
||||
id,
|
||||
Position {
|
||||
x: index as f32 * 300.,
|
||||
y: 400.,
|
||||
},
|
||||
);
|
||||
}
|
||||
let port_owner = if layer == Layer::Sa { work } else { foreign };
|
||||
let port_id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&project,
|
||||
port_id,
|
||||
port_owner,
|
||||
"Work input",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project.elements.insert(port_id, port);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: app.project().diagrams[&diagram].name.clone(),
|
||||
}));
|
||||
let before = app.document.bundle().clone();
|
||||
let original = app.project().clone();
|
||||
let revision = app.document.revision();
|
||||
send(&mut app, Event::Choose(Some(Tool::Allocation)));
|
||||
assert!(
|
||||
app.connection
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidates
|
||||
.contains(&foreign_performer)
|
||||
);
|
||||
let context = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(context, performer));
|
||||
assert!(app.connection.as_ref().unwrap().candidates.contains(&work));
|
||||
assert!(Tool::Allocation.can_connect(app.project(), performer, hidden));
|
||||
for target in [composite, occupied, foreign, port_id, hidden, performer] {
|
||||
assert!(
|
||||
!app.connection
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidates
|
||||
.contains(&target)
|
||||
);
|
||||
send(&mut app, Event::Endpoint(context, target));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.project(), &original);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
for target in [composite, occupied, foreign, port_id, performer] {
|
||||
assert!(
|
||||
Tool::Allocation
|
||||
.command(app.project(), performer, target)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
send(&mut app, Event::Endpoint(context, work));
|
||||
assert_eq!(
|
||||
app.document.revision(),
|
||||
revision + 1,
|
||||
"Invalid clicks do not prevent recovery"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_creation_preserves_saved_edge_filters() {
|
||||
let (base, performer, work, diagram) =
|
||||
allocation_fixture(Layer::Oa, ProjectionKind::Decomposition);
|
||||
let mut project = base.project().clone();
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.relation_kinds = oa_core::view_options::RelationKinds::Only(vec![]);
|
||||
let diagrams = project.diagrams.clone();
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: app.project().diagrams[&diagram].name.clone(),
|
||||
}));
|
||||
let context = start_allocation(&mut app, performer);
|
||||
send(&mut app, Event::Endpoint(context, work));
|
||||
assert_eq!(
|
||||
app.project().relations[&app.selected].kind(),
|
||||
RelationKind::Allocation
|
||||
);
|
||||
assert_eq!(app.project().diagrams, diagrams);
|
||||
assert!(project_view(app.project(), diagram).edges.is_empty());
|
||||
assert!(app.status.contains("view filters are unchanged"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_mixed_view_keeps_captured_canvas_for_opposite_layer_pairs() {
|
||||
for owner_layer in [Layer::Oa, Layer::Sa] {
|
||||
for app_layer in [Layer::Oa, Layer::Sa] {
|
||||
let (base, owner_performer, owner_work, diagram) =
|
||||
allocation_fixture(owner_layer, ProjectionKind::Traceability);
|
||||
let mut project = base.project().clone();
|
||||
let layer = if owner_layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let (performer_kind, work_kind) = if layer == Layer::Oa {
|
||||
(Kind::Actor, Kind::Activity)
|
||||
} else {
|
||||
(Kind::SystemActor, Kind::Function)
|
||||
};
|
||||
let performer = project.add(performer_kind, "Opposite-layer performer", 40., 60.);
|
||||
let work = project.add(work_kind, "Opposite-layer work", 420., 60.);
|
||||
project.place(diagram, performer, Position { x: 40., y: 350. });
|
||||
project.place(diagram, work, Position { x: 420., y: 350. });
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: app.project().diagrams[&diagram].name.clone(),
|
||||
}));
|
||||
let _ = app.update(Message::Pan(iced::Vector::new(73., 29.)));
|
||||
app.layer = app_layer;
|
||||
let camera = (app.pan, app.zoom);
|
||||
let before = app.document.bundle().clone();
|
||||
let original = app.project().clone();
|
||||
let revision = app.document.revision();
|
||||
let cancelled = start_allocation(&mut app, performer);
|
||||
let _ = app.update(Message::Escape);
|
||||
send(&mut app, Event::Endpoint(cancelled, work));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.layer, app_layer);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!((app.pan, app.zoom), camera);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let context = start_allocation(&mut app, performer);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.layer, app_layer);
|
||||
assert_eq!(app.route_context(), Some(context));
|
||||
assert_eq!((app.pan, app.zoom), camera);
|
||||
assert_eq!(app.selected, performer);
|
||||
assert!(app.connection.as_ref().unwrap().candidates.contains(&work));
|
||||
for wrong_target in [owner_performer, owner_work] {
|
||||
assert!(
|
||||
!app.connection
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidates
|
||||
.contains(&wrong_target)
|
||||
);
|
||||
send(&mut app, Event::Endpoint(context, wrong_target));
|
||||
assert_eq!(app.project(), &original);
|
||||
assert_eq!(app.layer, app_layer);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!((app.pan, app.zoom), camera);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
send(&mut app, Event::Endpoint(context, work));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.layer, owner_layer);
|
||||
assert_eq!((app.pan, app.zoom), camera);
|
||||
let relation = &app.project().relations[&app.selected];
|
||||
assert_eq!(
|
||||
relation.body,
|
||||
RelationBody::new(RelationKind::Allocation, performer, work)
|
||||
);
|
||||
let mut expected = original.clone();
|
||||
expected.relations.insert(relation.id, relation.clone());
|
||||
assert_eq!(app.project(), &expected);
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &original);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!((app.pan, app.zoom), camera);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!((app.pan, app.zoom), camera);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_cancel_navigation_stale_context_and_pending_edits_preserve_model() {
|
||||
let (mut app, performer, work, diagram) =
|
||||
allocation_fixture(Layer::Oa, ProjectionKind::Decomposition);
|
||||
let original = app.project().clone();
|
||||
let before = app.document.bundle().clone();
|
||||
let old = start_allocation(&mut app, performer);
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.connection.is_none());
|
||||
send(&mut app, Event::Endpoint(old, work));
|
||||
let current = start_allocation(&mut app, performer);
|
||||
send(&mut app, Event::Endpoint(old, work));
|
||||
assert_eq!(app.connection.as_ref().unwrap().context, current);
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert!(app.connection.is_none());
|
||||
let _ = app.update(Message::Page(Page::Diagram));
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: app.project().diagrams[&diagram].name.clone(),
|
||||
}));
|
||||
start_allocation(&mut app, performer);
|
||||
send(&mut app, Event::Choose(None));
|
||||
assert!(app.connection.is_none());
|
||||
start_allocation(&mut app, performer);
|
||||
send(&mut app, Event::Choose(Some(Tool::Interaction)));
|
||||
assert_eq!(app.connection.as_ref().unwrap().tool, Tool::Interaction);
|
||||
assert_eq!(app.project(), &original);
|
||||
let current = start_allocation(&mut app, performer);
|
||||
for stale in [
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
diagram: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
revision: current.revision + 1,
|
||||
..current
|
||||
},
|
||||
] {
|
||||
send(&mut app, Event::Endpoint(stale, work));
|
||||
assert_eq!(app.connection.as_ref().unwrap().context, current);
|
||||
}
|
||||
app.delete_pending = true;
|
||||
send(&mut app, Event::Endpoint(current, work));
|
||||
assert_eq!(app.project(), &original);
|
||||
let _ = app.update(Message::CancelDelete);
|
||||
let _ = app.update(Message::Escape);
|
||||
let _ = app.update(Message::Select(performer));
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied performer name".into(),
|
||||
));
|
||||
send(&mut app, Event::Choose(Some(Tool::Allocation)));
|
||||
assert!(app.connection.is_none());
|
||||
assert!(app.draft_dirty());
|
||||
assert_eq!(app.project(), &original);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
|
||||
let (mut source_app, performer, work, _) =
|
||||
allocation_fixture(Layer::Sa, ProjectionKind::Decomposition);
|
||||
let before = source_app.document.bundle().clone();
|
||||
let context = start_allocation(&mut source_app, performer);
|
||||
use iced::widget::text_editor;
|
||||
let _ = source_app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = source_app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(std::sync::Arc::new("invalid raw source retained".into())),
|
||||
)));
|
||||
send(&mut source_app, Event::Endpoint(context, work));
|
||||
send(&mut source_app, Event::Choose(Some(Tool::Allocation)));
|
||||
assert!(source_app.connection.is_none());
|
||||
assert!(source_app.source_dirty());
|
||||
assert_eq!(
|
||||
source_app.source_editor.text(),
|
||||
"invalid raw source retained"
|
||||
);
|
||||
assert_eq!(source_app.document.bundle(), &before);
|
||||
}
|
||||
@@ -52,6 +52,8 @@ mod tests {
|
||||
use crate::app::{App, Message, Page};
|
||||
use oa_core::*;
|
||||
|
||||
include!("connection_allocation_tests.rs");
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Send command", 0., 0.);
|
||||
|
||||
@@ -9,13 +9,18 @@ pub enum Tool {
|
||||
CommunicationMean,
|
||||
FunctionalExchange,
|
||||
BoundaryExchange,
|
||||
Allocation,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn for_layer(layer: Layer) -> &'static [Self] {
|
||||
match layer {
|
||||
Layer::Oa => &[Self::Interaction, Self::CommunicationMean],
|
||||
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
|
||||
Layer::Oa => &[Self::Interaction, Self::CommunicationMean, Self::Allocation],
|
||||
Layer::Sa => &[
|
||||
Self::FunctionalExchange,
|
||||
Self::BoundaryExchange,
|
||||
Self::Allocation,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +30,15 @@ impl Tool {
|
||||
Self::CommunicationMean => "Communication means",
|
||||
Self::FunctionalExchange => "Functional exchange",
|
||||
Self::BoundaryExchange => "Boundary exchange",
|
||||
Self::Allocation => "Allocation",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Allocation => {
|
||||
"Click a performer, then highlighted unallocated leaf work. In allocated-work views, allocation appears as nesting rather than a line."
|
||||
}
|
||||
Self::Interaction => "Click a source activity, then a target activity.",
|
||||
Self::CommunicationMean => {
|
||||
"Click an operational entity or actor, then another participant. Communication is bidirectional unless you change its orientation."
|
||||
@@ -45,6 +54,10 @@ impl Tool {
|
||||
|
||||
pub fn can_start(self, project: &Project, source: Id) -> bool {
|
||||
match self {
|
||||
Self::Allocation => project
|
||||
.elements
|
||||
.get(&source)
|
||||
.is_some_and(|e| e.kind().is_performer()),
|
||||
Self::CommunicationMean => project.elements.get(&source).is_some_and(|e| {
|
||||
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
|
||||
}),
|
||||
@@ -65,6 +78,9 @@ impl Tool {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
Self::Allocation => {
|
||||
validation::can_connect(project, RelationKind::Allocation, source, target)
|
||||
}
|
||||
Self::CommunicationMean => {
|
||||
validation::can_connect(project, RelationKind::CommunicationMean, source, target)
|
||||
}
|
||||
@@ -140,6 +156,7 @@ impl Tool {
|
||||
}
|
||||
let name = engineering_name(project, self, source, target);
|
||||
let body = match self {
|
||||
Self::Allocation => RelationBody::new(RelationKind::Allocation, source, target),
|
||||
Self::CommunicationMean => {
|
||||
RelationBody::new(RelationKind::CommunicationMean, source, target)
|
||||
}
|
||||
@@ -162,6 +179,28 @@ impl Tool {
|
||||
prepare_command(project, &command)?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
/// Both endpoint categories must be supported before offering Allocation.
|
||||
/// Preserve the established projection eligibility of the exchange tools.
|
||||
pub fn available_in(self, layer: Layer, projection: ProjectionKind) -> bool {
|
||||
if !Self::for_layer(layer).contains(&self) {
|
||||
return false;
|
||||
}
|
||||
let kind = match self {
|
||||
Self::Interaction => Kind::Activity,
|
||||
Self::CommunicationMean => Kind::Entity,
|
||||
Self::FunctionalExchange => Kind::Function,
|
||||
Self::BoundaryExchange => Kind::System,
|
||||
Self::Allocation => {
|
||||
let (performer, work) = match layer {
|
||||
Layer::Oa => (Kind::Entity, Kind::Activity),
|
||||
Layer::Sa => (Kind::System, Kind::Function),
|
||||
};
|
||||
return projection.includes(performer) && projection.includes(work);
|
||||
}
|
||||
};
|
||||
projection.includes(kind)
|
||||
}
|
||||
}
|
||||
|
||||
/// A clicked port determines its own function owner. It cannot be paired with
|
||||
@@ -291,7 +330,7 @@ mod tests {
|
||||
let before = p.clone();
|
||||
assert_eq!(
|
||||
Tool::for_layer(Layer::Oa),
|
||||
&[Tool::Interaction, Tool::CommunicationMean]
|
||||
&[Tool::Interaction, Tool::CommunicationMean, Tool::Allocation]
|
||||
);
|
||||
assert_eq!(
|
||||
Tool::Interaction.targets(&p, source),
|
||||
@@ -592,6 +631,8 @@ mod tests {
|
||||
(Tool::CommunicationMean, oa_team, oa_actor),
|
||||
(Tool::FunctionalExchange, sa_a, sa_b),
|
||||
(Tool::BoundaryExchange, boundary_a, boundary_b),
|
||||
(Tool::Allocation, oa_team, oa_a),
|
||||
(Tool::Allocation, system, sa_a),
|
||||
] {
|
||||
let before = document.bundle().clone();
|
||||
let original = document.project().clone();
|
||||
|
||||
@@ -22,6 +22,9 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/attachment_tests.rs"]
|
||||
mod attachment_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/lifecycle_tests.rs"]
|
||||
mod lifecycle_tests;
|
||||
#[cfg(test)]
|
||||
@@ -53,6 +56,7 @@ pub struct DiagramCanvas<'a> {
|
||||
pub pan: Vector,
|
||||
pub context: Context,
|
||||
pub selected_bend: Option<usize>,
|
||||
pub selected_attachment: Option<EdgeEnd>,
|
||||
pub editable: bool,
|
||||
pub placing: bool,
|
||||
pub connection: Option<&'a crate::connection_edit::Session>,
|
||||
@@ -67,6 +71,7 @@ pub struct State {
|
||||
pan_start: Option<(mouse::Button, Point)>,
|
||||
context: Option<Context>,
|
||||
route_drag: Option<Gesture>,
|
||||
attachment_viewport: Option<(f32, Vector, Rectangle)>,
|
||||
connection_press: Option<(Id, Point)>,
|
||||
connection_hover: Option<Point>,
|
||||
port_drag: Option<crate::port_edit::Gesture>,
|
||||
@@ -294,6 +299,11 @@ impl DiagramCanvas<'_> {
|
||||
);
|
||||
request.canvas = Some(self.context);
|
||||
request.creation_point = Some(position(world));
|
||||
if self.attachment_at(&scene, world).is_some() {
|
||||
request.object = self.selected;
|
||||
request.route_point = Some(position(world));
|
||||
return request;
|
||||
}
|
||||
if let Some(edge) = scene.routed.edges.get(&self.selected)
|
||||
&& !edge.custom_suspended
|
||||
&& let Some(index) = edge
|
||||
@@ -371,6 +381,39 @@ impl DiagramCanvas<'_> {
|
||||
})
|
||||
.map(|(id, segment, _)| (id, segment))
|
||||
}
|
||||
fn attachment_at<'b>(
|
||||
&self,
|
||||
scene: &'b Scene,
|
||||
world: Point,
|
||||
) -> Option<(EdgeEnd, &'b ProjectedNode)> {
|
||||
let edge = scene.routed.edges.get(&self.selected)?;
|
||||
if edge.custom_suspended {
|
||||
return None;
|
||||
}
|
||||
[
|
||||
(EdgeEnd::Source, edge.points.first()),
|
||||
(EdgeEnd::Target, edge.points.last()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(end, at)| {
|
||||
let distance = world.distance(point(*at?));
|
||||
let owner = route_edit::attachment_node(
|
||||
self.project,
|
||||
&self.project.diagrams[&self.diagram],
|
||||
&scene.view,
|
||||
self.selected,
|
||||
end,
|
||||
)?;
|
||||
(distance <= 8. / self.zoom).then_some((end, owner, distance))
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
a.2.total_cmp(&b.2).then_with(|| {
|
||||
(Some(a.0) != self.selected_attachment)
|
||||
.cmp(&(Some(b.0) != self.selected_attachment))
|
||||
})
|
||||
})
|
||||
.map(|(end, owner, _)| (end, owner))
|
||||
}
|
||||
fn hit_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
|
||||
let p = self.world(p);
|
||||
routed
|
||||
@@ -561,6 +604,24 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
// An attachment belongs to the boundary geometry captured at press.
|
||||
// Camera/layout changes must not reinterpret its pointer in another space.
|
||||
if matches!(
|
||||
state.route_drag.as_ref().map(|gesture| &gesture.handle),
|
||||
Some(Handle::Attachment { .. })
|
||||
) && (state.attachment_viewport != Some((self.zoom, self.pan, bounds))
|
||||
|| matches!(
|
||||
event,
|
||||
canvas::Event::Window(
|
||||
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
|
||||
) | canvas::Event::Mouse(mouse::Event::CursorLeft)
|
||||
))
|
||||
{
|
||||
state.route_drag = None;
|
||||
state.attachment_viewport = None;
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
// A completed work click retires App's allocation epoch before the
|
||||
// retained canvas sees a redraw. Its already-idle press must not eat
|
||||
// the first Delete. A live or not-yet-dispatched press stays guarded.
|
||||
@@ -840,6 +901,21 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
&self.project.diagrams[&self.diagram],
|
||||
self.selected,
|
||||
);
|
||||
if let Some((end, owner)) = self.attachment_at(&scene, world) {
|
||||
let original = route.attachment(end);
|
||||
self.begin_route(
|
||||
state,
|
||||
world,
|
||||
Handle::Attachment {
|
||||
end,
|
||||
owner: owner.clone(),
|
||||
original,
|
||||
},
|
||||
route,
|
||||
);
|
||||
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
|
||||
return Some(Action::capture());
|
||||
}
|
||||
if let Some(index) = edge
|
||||
.pins
|
||||
.iter()
|
||||
@@ -963,7 +1039,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
if let Some(gesture) = &mut state.route_drag {
|
||||
gesture.move_to(self.world(p), self.zoom);
|
||||
gesture.move_attachment_to(self.world(p), self.zoom);
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let Some((_, start, original)) = state.drag {
|
||||
@@ -1040,17 +1116,43 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
if let Some(mut gesture) = state.route_drag.take() {
|
||||
if matches!(gesture.handle, Handle::Attachment { .. }) {
|
||||
state.attachment_viewport = None;
|
||||
if !cursor.is_over(bounds) {
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
}
|
||||
// `local` uses the ordered pointer position at release,
|
||||
// before any later motion in the same native event batch.
|
||||
if let Some(p) = local {
|
||||
gesture.move_to(self.world(p), self.zoom);
|
||||
gesture.move_attachment_to(self.world(p), self.zoom);
|
||||
}
|
||||
if !gesture.changed {
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let Handle::Attachment { end, owner, .. } = &gesture.handle {
|
||||
if gesture.route
|
||||
== route_edit::stored_route(
|
||||
&self.project.diagrams[&self.diagram],
|
||||
self.selected,
|
||||
)
|
||||
{
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
return Some(
|
||||
Action::publish(Message::Route(RouteEvent::CommitAttachment(
|
||||
gesture.context,
|
||||
gesture.route,
|
||||
*end,
|
||||
owner.id,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let bend = match gesture.handle {
|
||||
Handle::Bend(index) => Some(index),
|
||||
Handle::Label { .. } => None,
|
||||
Handle::Attachment { .. } => unreachable!(),
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Message::Route(RouteEvent::Commit(
|
||||
@@ -1094,6 +1196,15 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
|
||||
if cursor.is_over(bounds) =>
|
||||
{
|
||||
// Cancel before publishing Zoom: a release may arrive in this
|
||||
// same event batch, before App applies the new camera.
|
||||
if matches!(
|
||||
state.route_drag.as_ref().map(|gesture| &gesture.handle),
|
||||
Some(Handle::Attachment { .. })
|
||||
) {
|
||||
state.route_drag = None;
|
||||
state.attachment_viewport = None;
|
||||
}
|
||||
let y = match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
|
||||
*y
|
||||
@@ -1336,6 +1447,41 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
if let Some(edge) = scene.routed.edges.get(&self.selected)
|
||||
&& !edge.custom_suspended
|
||||
{
|
||||
for (end, at) in [
|
||||
(EdgeEnd::Source, edge.points.first()),
|
||||
(EdgeEnd::Target, edge.points.last()),
|
||||
] {
|
||||
if let Some(at) = at
|
||||
&& route_edit::attachment_node(
|
||||
project,
|
||||
&project.diagrams[&self.diagram],
|
||||
view,
|
||||
self.selected,
|
||||
end,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
let at = point(*at);
|
||||
let handle = Path::rectangle(
|
||||
at - Vector::new(5.5 / self.zoom, 5.5 / self.zoom),
|
||||
Size::new(11. / self.zoom, 11. / self.zoom),
|
||||
);
|
||||
f.fill(
|
||||
&handle,
|
||||
if self.selected_attachment == Some(end) {
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::WHITE
|
||||
},
|
||||
);
|
||||
f.stroke(
|
||||
&handle,
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(1.7 / self.zoom),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (index, pin) in edge.pins.iter().enumerate() {
|
||||
let handle = Path::circle(point(*pin), 5.5 / self.zoom);
|
||||
f.fill(
|
||||
@@ -2042,6 +2188,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2118,6 +2265,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2243,6 +2391,7 @@ mod tests {
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
zoom,
|
||||
pan: Vector::new(20., 30.),
|
||||
editable: true,
|
||||
@@ -2380,6 +2529,8 @@ mod tests {
|
||||
diagram,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 400., y: -100. }],
|
||||
label: None,
|
||||
},
|
||||
@@ -2422,6 +2573,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2499,6 +2651,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2597,6 +2750,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2726,6 +2880,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2781,6 +2936,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2856,6 +3012,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2890,6 +3047,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
use super::*;
|
||||
use crate::app::{App, Page};
|
||||
use iced::widget::canvas::Program;
|
||||
|
||||
fn fixture() -> App {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Activity, "Send", 100., 150.);
|
||||
let b = p.add(Kind::Activity, "Receive", 650., 150.);
|
||||
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![Position { x: 450., y: 350. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: Position { x: 5., y: 10. },
|
||||
}),
|
||||
source_attachment: None,
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.6,
|
||||
}),
|
||||
});
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.active_diagram = Some(diagram);
|
||||
let _ = app.update(Message::Select(edge));
|
||||
app
|
||||
}
|
||||
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
allocation: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
navigation_focus: None,
|
||||
zoom,
|
||||
pan: Vector::new(40., 50.),
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: app
|
||||
.selected_attachment
|
||||
.filter(|(c, e, _)| Some(*c) == app.route_context() && *e == app.selected)
|
||||
.map(|(_, _, end)| end),
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
placing: false,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
|
||||
}
|
||||
fn screen(c: &DiagramCanvas<'_>, p: Position) -> Point {
|
||||
Point::new(
|
||||
bounds().x + p.x * c.zoom + c.pan.x,
|
||||
bounds().y + p.y * c.zoom + c.pan.y,
|
||||
)
|
||||
}
|
||||
fn send(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: mouse::Event,
|
||||
final_cursor: Point,
|
||||
) -> Option<Message> {
|
||||
c.update(
|
||||
state,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(final_cursor),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn positions(c: &DiagramCanvas<'_>) -> (Point, Point) {
|
||||
let scene = c.scene(&State::default());
|
||||
let start = *scene.routed.edges[&c.selected].points.first().unwrap();
|
||||
let node = route_edit::attachment_node(
|
||||
c.project,
|
||||
&c.project.diagrams[&c.diagram],
|
||||
&scene.view,
|
||||
c.selected,
|
||||
EdgeEnd::Source,
|
||||
)
|
||||
.unwrap();
|
||||
let end = oa_core::routing::attachment_point(
|
||||
c.project,
|
||||
&c.project.diagrams[&c.diagram],
|
||||
node,
|
||||
&EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25,
|
||||
},
|
||||
);
|
||||
(screen(c, start), screen(c, end))
|
||||
}
|
||||
fn drag(app: &App, zoom: f32) -> Message {
|
||||
let c = canvas(app, zoom);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
let messages = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| send(&c, &mut state, e, end))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(messages.len(), 1);
|
||||
messages.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_event_batches_commit_one_source_history_change_at_three_zooms() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let mut app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let project = app.project().clone();
|
||||
let revision = app.document.revision();
|
||||
let message = drag(&app, zoom);
|
||||
let Message::Route(RouteEvent::CommitAttachment(_, ref route, EdgeEnd::Source, _)) =
|
||||
message
|
||||
else {
|
||||
panic!("attachment commit required")
|
||||
};
|
||||
assert_eq!(
|
||||
route.source_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
let intended = route.clone();
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let mut expected = project;
|
||||
route_edit::with_route(
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&app.active_diagram.unwrap())
|
||||
.unwrap(),
|
||||
intended,
|
||||
);
|
||||
assert_eq!(
|
||||
*app.project(),
|
||||
expected,
|
||||
"only the intended view attachment changes"
|
||||
);
|
||||
let after = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&after, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_stationary_return_and_escape_leave_source_unchanged() {
|
||||
let mut app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let c = canvas(&app, 1.);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
assert!(send(&c, &mut state, event, start).is_none());
|
||||
}
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
] {
|
||||
assert!(send(&c, &mut state, event, end).is_none());
|
||||
}
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(
|
||||
send(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_camera_layout_focus_and_outside_release_cancel_after_motion() {
|
||||
for cancel in 0..8 {
|
||||
let app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let mut c = canvas(&app, 1.);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
] {
|
||||
assert!(send(&c, &mut state, event, end).is_none());
|
||||
}
|
||||
assert!(state.route_drag.as_ref().is_some_and(|g| g.changed));
|
||||
let mut current_bounds = bounds();
|
||||
let mut release = end;
|
||||
match cancel {
|
||||
0 => c.zoom = 2.,
|
||||
1 => c.pan.x += 20.,
|
||||
2 => current_bounds.x += 20.,
|
||||
3 => current_bounds.height -= 100.,
|
||||
4..=6 => {
|
||||
let event = match cancel {
|
||||
4 => canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
5 => canvas::Event::Window(iced::window::Event::Resized(Size::new(800., 600.))),
|
||||
_ => canvas::Event::Mouse(mouse::Event::CursorLeft),
|
||||
};
|
||||
let message = c
|
||||
.update(
|
||||
&mut state,
|
||||
&event,
|
||||
current_bounds,
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0);
|
||||
assert!(message.is_none());
|
||||
}
|
||||
_ => {
|
||||
release = Point::new(-100., -100.);
|
||||
assert!(
|
||||
send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: release },
|
||||
release
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
let message = c
|
||||
.update(
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
current_bounds,
|
||||
mouse::Cursor::Available(release),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0);
|
||||
assert!(message.is_none(), "cancellation case {cancel}");
|
||||
assert!(state.route_drag.is_none(), "cancellation case {cancel}");
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_wheel_and_middle_pan_cancel_before_same_batch_left_release() {
|
||||
for wheel in [true, false] {
|
||||
let mut app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let c = canvas(&app, 1.);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
] {
|
||||
assert!(send(&c, &mut state, event, end).is_none());
|
||||
}
|
||||
let camera_message = send(
|
||||
&c,
|
||||
&mut state,
|
||||
if wheel {
|
||||
mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
}
|
||||
} else {
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle)
|
||||
},
|
||||
end,
|
||||
);
|
||||
assert_eq!(camera_message.is_some(), wheel);
|
||||
assert!(state.route_drag.is_none());
|
||||
// Deliberately retain the same canvas and defer App camera dispatch.
|
||||
assert!(
|
||||
send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
if !wheel {
|
||||
assert!(
|
||||
send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
end
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
if let Some(message) = camera_message {
|
||||
assert!(matches!(message, Message::Zoom(_)));
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_fast_release_uses_release_position_and_stays_in_one_view() {
|
||||
let mut app = fixture();
|
||||
let original = app.active_diagram.unwrap();
|
||||
let _ = app.update(Message::DuplicateDiagram(original));
|
||||
let other = app.active_diagram.unwrap();
|
||||
assert_ne!(original, other);
|
||||
app.active_diagram = Some(original);
|
||||
let edge = app.project().diagrams[&original].routes[0].edge;
|
||||
let _ = app.update(Message::Select(edge));
|
||||
let untouched = app.project().diagrams[&other].clone();
|
||||
let c = canvas(&app, 0.7);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
assert!(
|
||||
send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
start
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let message = send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
)
|
||||
.expect("release completes drag without final move");
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&original].routes[0].source_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
assert_eq!(app.project().diagrams[&other], untouched);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_stale_context_wrong_placement_and_unowned_route_fields_are_rejected() {
|
||||
let mut app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let message = drag(&app, 1.);
|
||||
let Message::Route(RouteEvent::CommitAttachment(context, route, end, node)) = message else {
|
||||
panic!()
|
||||
};
|
||||
let mut changed = route.clone();
|
||||
changed.waypoints.clear();
|
||||
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
|
||||
context, changed, end, node,
|
||||
)));
|
||||
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
|
||||
context,
|
||||
route.clone(),
|
||||
end,
|
||||
Id::new_v4(),
|
||||
)));
|
||||
let _ = app.update(Message::Escape);
|
||||
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
|
||||
context, route, end, node,
|
||||
)));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_reset_preserves_opposite_end_pins_label_and_semantics() {
|
||||
let mut app = fixture();
|
||||
let before = app.project().clone();
|
||||
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
|
||||
let mut expected = before;
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&app.active_diagram.unwrap())
|
||||
.unwrap()
|
||||
.routes[0]
|
||||
.target_attachment = None;
|
||||
assert_eq!(*app.project(), expected);
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_attachment_handles_honor_explicit_end_choice() {
|
||||
let mut app = fixture();
|
||||
let _ = app.update(Message::Route(RouteEvent::ChooseAttachment(
|
||||
EdgeEnd::Target,
|
||||
)));
|
||||
let c = canvas(&app, 1.);
|
||||
let mut scene = Scene {
|
||||
work: None,
|
||||
view: projection::project_view(c.project, c.diagram),
|
||||
routed: Arc::new(route_diagram(
|
||||
c.project,
|
||||
&c.project.diagrams[&c.diagram],
|
||||
&projection::project_view(c.project, c.diagram),
|
||||
)),
|
||||
};
|
||||
let routed = Arc::make_mut(&mut scene.routed);
|
||||
let edge = routed.edges.get_mut(&c.selected).unwrap();
|
||||
let start = *edge.points.first().unwrap();
|
||||
*edge.points.last_mut().unwrap() = start;
|
||||
assert_eq!(
|
||||
c.attachment_at(&scene, point(start)).unwrap().0,
|
||||
EdgeEnd::Target
|
||||
);
|
||||
let mut source = c;
|
||||
source.selected_attachment = Some(EdgeEnd::Source);
|
||||
assert_eq!(
|
||||
source.attachment_at(&scene, point(start)).unwrap().0,
|
||||
EdgeEnd::Source
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
}
|
||||
if state.context.is_none_or(|previous| {
|
||||
(previous.document, previous.diagram, previous.epoch)
|
||||
!= (self.context.document, self.context.diagram, self.context.epoch)
|
||||
!= (
|
||||
self.context.document,
|
||||
self.context.diagram,
|
||||
self.context.epoch,
|
||||
)
|
||||
}) || !self.enabled
|
||||
{
|
||||
state.focused = false;
|
||||
|
||||
@@ -62,6 +62,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
|
||||
@@ -70,6 +70,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
|
||||
@@ -16,6 +16,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -186,6 +186,7 @@ mod tests {
|
||||
pan: Vector::ZERO,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -263,7 +263,8 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
|
||||
"In the diagram's side palette, choose Activity in OA or Function in SA, click its position, enter a name and press Enter. Escape cancels without creating an element. Select a card and press F2 to rename it in place. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.",
|
||||
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. + Relationship with Allocation and Model explorer's + Allocated provide explicit alternatives.",
|
||||
"To allocate existing leaf work in Containment, choose Allocation in the diagram palette. Click a visible performer, then the highlighted Activity or Function it performs. The work must belong to the performer's layer and have no direct performer yet. In a mixed OA ↔ SA traceability view, either layer's performer can be used and the current view stays open. Completing the allocation sets the application perspective to that view's layer; choosing only a source or canceling leaves the prior perspective unchanged. Escape cancels. This creates a responsibility relationship without changing the work's semantic parent.",
|
||||
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. Create relationship… with Allocation and Model explorer's + Allocated provide explicit alternatives.",
|
||||
"Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.",
|
||||
],
|
||||
),
|
||||
@@ -307,8 +308,8 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose and edit the relationship you mean",
|
||||
&[
|
||||
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.",
|
||||
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships is a broader exploration view; OA ↔ SA traceability displays counterparts.",
|
||||
"Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.",
|
||||
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships explores one layer; OA ↔ SA traceability can show placed elements from both layers and their correspondence lines alongside ordinary relationships.",
|
||||
"Select an element and edit Semantic parent to change ownership. Use the Allocation palette tool to connect a performer to existing unallocated leaf work in Containment. The relationship can appear as a line there; Architecture · allocated work shows responsibility through nesting instead. Saved visibility filters can hide a line without removing the relationship.",
|
||||
"Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange next drag or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.",
|
||||
"Select a diagram node to access Placement, Fold / unfold and Hide. With the canvas focused, Delete hides its placement; Shift+Delete asks to remove the model element. Other views retain a hidden element. Undo restores either change.",
|
||||
],
|
||||
@@ -317,6 +318,16 @@ fn article(topic: Topic) -> Article {
|
||||
"Keep diagrams readable",
|
||||
"Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.",
|
||||
),
|
||||
walkthrough(
|
||||
"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.",
|
||||
"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.",
|
||||
"If the counterpart is already placed, use Include already shown (Locate) in the chooser. Undo restores an added placement; saving preserves each view's placements independently. Use the Traceability page's existing counterpart or manual-trace workflow when the semantic link itself does not yet exist.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Move work between performers",
|
||||
&[
|
||||
@@ -344,7 +355,7 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
|
||||
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
|
||||
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
"Select a connection and drag a square endpoint handle along its element boundary to move its attachment in this view without changing what it connects. If handles overlap, right-click the selected connection or use its Actions menu and choose Move source attachment or Move target attachment, then drag the handle. Connections to semantic ports retain their port attachment; move the port instead. Reset source attachment or Reset target attachment restores that end's automatic placement while preserving the other end, pins and label. Reset routing clears all these route overrides in this view. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -564,7 +575,8 @@ fn article(topic: Topic) -> Article {
|
||||
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
|
||||
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
|
||||
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
|
||||
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
|
||||
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.",
|
||||
"To shape a selected transition, drag an exposed line segment to add a bend, or drag a square bend handle to move it. Right-click a square and choose Remove bend. The transition's Actions menu also offers Add bend and Clear bends; clearing bends keeps its attachment points and label placement. Bends belong to this saved view. A click alone changes nothing, Escape cancels a drag, and Undo restores the complete edit. Internal transition rows and folded connections do not expose bend editing while their route is hidden.",
|
||||
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
|
||||
],
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct Request {
|
||||
pub placement: Option<Id>,
|
||||
pub bend: Option<usize>,
|
||||
pub route_point: Option<Position>,
|
||||
pub route_segment: Option<(usize, Position)>,
|
||||
pub creation_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub source_file: Option<String>,
|
||||
@@ -26,6 +27,7 @@ impl Request {
|
||||
placement: None,
|
||||
bend: None,
|
||||
route_point: None,
|
||||
route_segment: None,
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
source_file: None,
|
||||
@@ -240,6 +242,33 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
.is_none_or(|(s, t)| s == e.source && t == e.target)
|
||||
});
|
||||
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
|
||||
if let Some((diagram, view)) = diagram.zip(view.as_ref()) {
|
||||
for (end, choose, reset) in [
|
||||
(
|
||||
EdgeEnd::Source,
|
||||
"Move source attachment",
|
||||
"Reset source attachment",
|
||||
),
|
||||
(
|
||||
EdgeEnd::Target,
|
||||
"Move target attachment",
|
||||
"Reset target attachment",
|
||||
),
|
||||
] {
|
||||
let eligible = editable
|
||||
&& route_edit::attachment_node(p, diagram, view, id, end).is_some();
|
||||
entries.push(item(
|
||||
choose,
|
||||
Message::Route(route_edit::Event::ChooseAttachment(end)),
|
||||
eligible,
|
||||
));
|
||||
entries.push(item(
|
||||
reset,
|
||||
Message::Route(route_edit::Event::ResetAttachment(end)),
|
||||
eligible && route.is_some_and(|route| route.attachment(end).is_some()),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(point) = request.route_point {
|
||||
entries.push(
|
||||
item(
|
||||
@@ -429,7 +458,8 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
entries.push(item(
|
||||
"Show in this diagram",
|
||||
Message::PlaceSelected,
|
||||
d.layer == e.layer() && d.projection.includes(e.kind()),
|
||||
(d.layer == e.layer() || d.projection == ProjectionKind::Traceability)
|
||||
&& d.projection.includes(e.kind()),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -560,6 +590,7 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)]
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
include!("model_menu_traceability_tests.rs");
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
@@ -900,6 +931,36 @@ mod tests {
|
||||
assert_eq!(app.document.bundle(), &changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() {
|
||||
let mut project = Project::empty();
|
||||
let source = project.add(Kind::Activity, "Send", 0., 0.);
|
||||
let target = project.add(Kind::Activity, "Receive", 650., 0.);
|
||||
let edge = project.relate(RelationKind::Interaction, "Report", source, target);
|
||||
let mut app = App::with_project(project);
|
||||
let before = app.document.bundle().clone();
|
||||
let mut request = Request::object(edge, Point::ORIGIN);
|
||||
request.canvas = app.route_context();
|
||||
send(&mut app, Message::Menu(Event::Open(request.clone())));
|
||||
let action = choose(&app, "Move target attachment");
|
||||
send(&mut app, action);
|
||||
assert_eq!(
|
||||
app.selected_attachment,
|
||||
Some((app.route_context().unwrap(), edge, EdgeEnd::Target))
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
request.canvas = app.route_context();
|
||||
send(&mut app, Message::Menu(Event::Open(request)));
|
||||
let stale = choose(&app, "Move source attachment");
|
||||
send(&mut app, Message::Escape);
|
||||
send(&mut app, stale);
|
||||
assert!(
|
||||
app.selected_attachment
|
||||
.is_none_or(|(context, _, _)| Some(context) != app.route_context())
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
|
||||
let mut p = Project::empty();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#[test]
|
||||
fn traceability_show_menu_matches_cross_layer_and_port_placement_eligibility() {
|
||||
let mut project = Project::empty();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Counterpart", None)
|
||||
.unwrap();
|
||||
let port = Id::new_v4();
|
||||
project.elements.insert(
|
||||
port,
|
||||
Element {
|
||||
id: port,
|
||||
alias: "legacy_port".into(),
|
||||
name: "Port".into(),
|
||||
description: String::new(),
|
||||
parent: Some(sa),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
oa_core::validate_candidate(&project).unwrap();
|
||||
for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] {
|
||||
let mut p = project.clone();
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = projection;
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
for object in [sa, port] {
|
||||
send(&mut app, open(object, Point::ORIGIN));
|
||||
let action = popup(&app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|e| match e {
|
||||
Entry::Action { label, message, .. } if label == "Show in this diagram" => {
|
||||
Some(message)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if object == port {
|
||||
assert!(
|
||||
action.is_none(),
|
||||
"Ports never acquire independent node placements"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
action.expect("non-port placement entry").is_some(),
|
||||
projection == ProjectionKind::Traceability
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ pub struct Context {
|
||||
pub enum Event {
|
||||
SelectBend(Id, usize),
|
||||
Commit(Context, EdgeRoute, Option<usize>),
|
||||
CommitAttachment(Context, EdgeRoute, EdgeEnd, Id),
|
||||
ChooseAttachment(EdgeEnd),
|
||||
ResetAttachment(EdgeEnd),
|
||||
AddBend,
|
||||
AddBendAt(oa_core::Position),
|
||||
RemoveBend,
|
||||
@@ -23,7 +26,14 @@ pub enum Event {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Handle {
|
||||
Bend(usize),
|
||||
Label { original: Position },
|
||||
Label {
|
||||
original: Position,
|
||||
},
|
||||
Attachment {
|
||||
end: EdgeEnd,
|
||||
owner: projection::ProjectedNode,
|
||||
original: Option<EdgeAttachment>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -58,8 +68,57 @@ impl Gesture {
|
||||
};
|
||||
}
|
||||
}
|
||||
Handle::Attachment { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_attachment_to(&mut self, point: Point, zoom: f32) {
|
||||
let Handle::Attachment {
|
||||
end,
|
||||
ref owner,
|
||||
original,
|
||||
} = self.handle
|
||||
else {
|
||||
self.move_to(point, zoom);
|
||||
return;
|
||||
};
|
||||
if point.distance(self.start) * zoom < 3. {
|
||||
self.route.set_attachment(end, original);
|
||||
self.changed = false;
|
||||
return;
|
||||
}
|
||||
self.changed = true;
|
||||
let attachment = attachment_from_point(
|
||||
owner,
|
||||
position(point),
|
||||
self.route.attachment(end).map(|a| a.side),
|
||||
6. / zoom,
|
||||
);
|
||||
self.route.set_attachment(end, Some(attachment));
|
||||
}
|
||||
}
|
||||
|
||||
/// The painted endpoint must still be the actual unported placement, not a folded proxy.
|
||||
pub fn attachment_node<'a>(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
view: &'a projection::ProjectedView,
|
||||
edge: Id,
|
||||
end: EdgeEnd,
|
||||
) -> Option<&'a projection::ProjectedNode> {
|
||||
if semantic_port_endpoint(project, edge, end).is_some() {
|
||||
return None;
|
||||
}
|
||||
let projected = view.edges.iter().find(|e| e.id == edge)?;
|
||||
let actual = projection::edge_endpoint_placements(project, diagram, edge)?;
|
||||
if actual != (projected.source, projected.target) {
|
||||
return None;
|
||||
}
|
||||
let placement = match end {
|
||||
EdgeEnd::Source => projected.source,
|
||||
EdgeEnd::Target => projected.target,
|
||||
};
|
||||
view.nodes.iter().find(|node| node.id == placement)
|
||||
}
|
||||
|
||||
pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
|
||||
@@ -72,10 +131,40 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
|
||||
edge,
|
||||
waypoints: Vec::new(),
|
||||
label: None,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert after the pins preceding the clicked segment along the routed path.
|
||||
pub fn insert_pin_on_segment(
|
||||
route: &mut EdgeRoute,
|
||||
geometry: &RoutedEdge,
|
||||
segment: usize,
|
||||
point: Position,
|
||||
) -> Option<usize> {
|
||||
if route.edge != geometry.id
|
||||
|| geometry.custom_suspended
|
||||
|| route.waypoints != geometry.pins
|
||||
|| route.waypoints.len() >= 128
|
||||
|| !point.x.is_finite()
|
||||
|| !point.y.is_finite()
|
||||
|| geometry
|
||||
.points
|
||||
.get(segment..segment.checked_add(2)?)
|
||||
.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let index = pin_insertion_index(geometry, segment, point);
|
||||
if index > route.waypoints.len() {
|
||||
return None;
|
||||
}
|
||||
route.waypoints.insert(index, point);
|
||||
Some(index)
|
||||
}
|
||||
|
||||
/// Find a segment when an ordinary caller has only a point, not a captured hit.
|
||||
pub fn insert_pin(route: &mut EdgeRoute, geometry: &RoutedEdge, point: Position) -> usize {
|
||||
let segment = geometry
|
||||
.points
|
||||
|
||||
@@ -329,6 +329,8 @@ fn reopening_canvas_for_a_modal_preserves_viewport_after_route_edits() {
|
||||
diagram,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 400., y: 400. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.5,
|
||||
|
||||
@@ -112,6 +112,8 @@ mod tests {
|
||||
});
|
||||
view.routes.push(EdgeRoute {
|
||||
edge: exchange,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 420., y: 440. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
|
||||
@@ -466,6 +466,12 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_attachment: app
|
||||
.selected_attachment
|
||||
.filter(|(context, edge, _)| {
|
||||
Some(*context) == app.route_context() && *edge == app.selected
|
||||
})
|
||||
.map(|(_, _, end)| end),
|
||||
selected_bend: app
|
||||
.selected_bend
|
||||
.filter(|(edge, _)| *edge == app.selected)
|
||||
@@ -593,6 +599,28 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
if let Some(diagram) = app.active_diagram {
|
||||
let view = oa_core::projection::project_view(app.project(), diagram);
|
||||
if projection == Some(ProjectionKind::Traceability) {
|
||||
let correspondence_lines = view.edges.iter().filter(|edge| edge.trace).count();
|
||||
tools = tools.push(
|
||||
column![
|
||||
label("Shows OA and SA elements together, with correspondence lines alongside ordinary relationships."),
|
||||
label("Place both ends of a trace to show its line. Port traces use their owner cards; traces between relationships are reviewed on the Traceability page."),
|
||||
row![
|
||||
label(format!("{correspondence_lines} correspondence lines in this view")),
|
||||
action(
|
||||
"Add existing elements…",
|
||||
Message::Architecture(crate::app::architecture_authoring::Event::Existing(None))
|
||||
),
|
||||
action("Review layer traces", Message::ReviewLayerTraces),
|
||||
action("View visibility…", Message::Select(diagram)),
|
||||
]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.wrap(),
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
}
|
||||
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
|
||||
let promoted = edge.is_some_and(|edge| {
|
||||
oa_core::projection::edge_endpoint_placements(
|
||||
|
||||
@@ -234,6 +234,8 @@ fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_e
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: trace,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 301., y: 507. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
|
||||
@@ -12,6 +12,8 @@ use tokio::{sync::oneshot, task::JoinHandle};
|
||||
mod communication_limits;
|
||||
#[path = "http/data_contracts.rs"]
|
||||
mod data_contracts;
|
||||
#[path = "http/edge_attachments.rs"]
|
||||
mod edge_attachments;
|
||||
#[path = "http/port_anchors.rs"]
|
||||
mod port_anchors;
|
||||
#[path = "http/semantic_transitions.rs"]
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
use super::*;
|
||||
use oa_core::{
|
||||
EdgeAttachment, PortAnchorSide,
|
||||
source_project::{ProjectLimits, SourceBundle, compile_bundle},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn source_bundle() -> SourceBundle {
|
||||
compile_bundle(&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "// project rationale\narcadia 7; project attachments \"Attachments\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
|
||||
("model.arcadia".into(), "// semantic ownership\noa_activity first \"First\" {}\noa_activity second \"Second\" {}\ninteraction signal \"Signal\" { source @first; target @second; }\n".into()),
|
||||
("views.arcadia".into(), "// view rationale\ndiagram view \"View\" { routes [{ edge @signal; waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; }]; }\ndiagram other \"Other\" { routes [{ edge @signal; }]; }\n".into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap().bundle
|
||||
}
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
async fn import(server: &TestServer) -> StoredProject {
|
||||
let response = server
|
||||
.client
|
||||
.post(server.url("/projects/import-source-project"))
|
||||
.json(&source_bundle())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let value: Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED, "{value}");
|
||||
server
|
||||
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn transact(
|
||||
server: &TestServer,
|
||||
current: &StoredProject,
|
||||
commands: Value,
|
||||
dry_run: bool,
|
||||
) -> reqwest::Response {
|
||||
server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&json!({"commands":commands,"dry_run":dry_run}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_pair(current: &StoredProject) {
|
||||
let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, current.model);
|
||||
assert_eq!(compiled.bundle, current.source_project);
|
||||
}
|
||||
|
||||
async fn assert_unchanged(server: &TestServer, expected: &StoredProject) {
|
||||
let actual = server.get(expected.resource_id).await;
|
||||
assert_eq!(actual.etag(), expected.etag());
|
||||
assert_eq!(
|
||||
serde_json::to_value(actual).unwrap(),
|
||||
serde_json::to_value(expected).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edge_attachments_http_dry_run_etags_source_parity_and_restart() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let original = import(&server).await;
|
||||
let diagram = id(&original.model, "view");
|
||||
let mut expected = original.model.clone();
|
||||
let route = &mut expected.diagrams.get_mut(&diagram).unwrap().routes[0];
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.25,
|
||||
});
|
||||
route.target_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 1.0,
|
||||
});
|
||||
let commands = json!([Command::SetEdgeRoute {
|
||||
diagram,
|
||||
route: route.clone()
|
||||
}]);
|
||||
let preview = transact(&server, &original, commands.clone(), true).await;
|
||||
assert_eq!(preview.status(), StatusCode::OK);
|
||||
let preview: Value = preview.json().await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap();
|
||||
assert_eq!(
|
||||
compile_bundle(&proposed, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
assert_unchanged(&server, &original).await;
|
||||
|
||||
let applied = transact(&server, &original, commands.clone(), false).await;
|
||||
assert_eq!(applied.status(), StatusCode::OK);
|
||||
let mut current = server.get(original.resource_id).await;
|
||||
assert_eq!(applied.headers()["etag"], current.etag());
|
||||
assert_eq!(current.revision, original.revision + 1);
|
||||
assert_eq!(
|
||||
current.model, expected,
|
||||
"only the two attachment fields in this view change"
|
||||
);
|
||||
assert_eq!(current.source_project, proposed);
|
||||
assert_pair(¤t);
|
||||
for path in ["project.arcadia", "model.arcadia"] {
|
||||
assert_eq!(
|
||||
current.source_project.files[path],
|
||||
original.source_project.files[path]
|
||||
);
|
||||
}
|
||||
assert!(current.source_project.files["views.arcadia"].contains("// view rationale"));
|
||||
let noop = transact(&server, ¤t, commands.clone(), false).await;
|
||||
assert_eq!(noop.status(), StatusCode::OK);
|
||||
assert_eq!(noop.headers()["etag"], current.etag());
|
||||
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
|
||||
let stale = json!([
|
||||
Command::Rename(id(¤t.model, "first"), "Must not persist".into()),
|
||||
commands[0].clone()
|
||||
]);
|
||||
assert_eq!(
|
||||
transact(&server, &original, stale, false).await.status(),
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
assert_unchanged(&server, ¤t).await;
|
||||
|
||||
let mut edited = current.source_project.clone();
|
||||
let text = edited.files.get_mut("views.arcadia").unwrap();
|
||||
assert_eq!(text.matches("offset 0.25;").count(), 1);
|
||||
*text = text.replace("offset 0.25;", "offset 0.375;");
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.source_attachment
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.offset = 0.375;
|
||||
let response = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&edited)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
current = server.get(current.resource_id).await;
|
||||
assert_eq!(current.model, expected);
|
||||
assert_eq!(current.source_project, edited);
|
||||
assert_pair(¤t);
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(source.status(), StatusCode::OK);
|
||||
assert_eq!(source.headers()["etag"], current.etag());
|
||||
assert_eq!(
|
||||
oa_core::language::parse(&source.text().await.unwrap()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let bundle = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{}/source-project", current.resource_id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bundle.status(), StatusCode::OK);
|
||||
assert_eq!(bundle.headers()["etag"], current.etag());
|
||||
assert_eq!(
|
||||
bundle.json::<SourceBundle>().await.unwrap(),
|
||||
current.source_project
|
||||
);
|
||||
server.stop().await;
|
||||
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
assert_unchanged(&server, ¤t).await;
|
||||
let mut route = expected.diagrams[&diagram].routes[0].clone();
|
||||
route.source_attachment = None;
|
||||
let reset = json!([Command::SetEdgeRoute {
|
||||
diagram,
|
||||
route: route.clone()
|
||||
}]);
|
||||
assert_eq!(
|
||||
transact(&server, ¤t, reset.clone(), false)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0] = route;
|
||||
let reset_state = server.get(current.resource_id).await;
|
||||
assert_eq!(
|
||||
reset_state.model, expected,
|
||||
"reset preserves pins, label, opposite end, other view and semantics"
|
||||
);
|
||||
assert_pair(&reset_state);
|
||||
let noop = transact(&server, &reset_state, reset, false).await;
|
||||
assert_eq!(noop.status(), StatusCode::OK);
|
||||
assert_eq!(noop.headers()["etag"], reset_state.etag());
|
||||
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edge_attachments_http_malformed_and_invalid_offsets_reject_atomically() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let current = import(&server).await;
|
||||
let diagram = id(¤t.model, "view");
|
||||
let first = id(¤t.model, "first");
|
||||
for (bad, status) in [
|
||||
(json!({}), StatusCode::BAD_REQUEST),
|
||||
(json!({"side":"Top"}), StatusCode::BAD_REQUEST),
|
||||
(json!({"offset":0.5}), StatusCode::BAD_REQUEST),
|
||||
(
|
||||
json!({"side":"Diagonal","offset":0.5}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(json!({"side":"Top","offset":null}), StatusCode::BAD_REQUEST),
|
||||
(
|
||||
json!({"side":"Top","offset":"NaN"}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
json!({"side":"Top","offset":0.5,"unknown":true}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
json!({"side":"Top","offset":-0.01}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"side":"Top","offset":1.01}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
] {
|
||||
for field in ["source_attachment", "target_attachment"] {
|
||||
let mut route =
|
||||
serde_json::to_value(¤t.model.diagrams[&diagram].routes[0]).unwrap();
|
||||
route[field] = bad.clone();
|
||||
let commands = json!([Command::Rename(first, "Must roll back".into()), {"SetEdgeRoute":{"diagram":diagram,"route":route}}]);
|
||||
let response = transact(&server, ¤t, commands, false).await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
status,
|
||||
"{field} {bad}: {}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
assert_unchanged(&server, ¤t).await;
|
||||
}
|
||||
}
|
||||
let mut invalid = current.model.clone();
|
||||
invalid.diagrams.get_mut(&diagram).unwrap().routes[0].target_attachment =
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 2.0,
|
||||
});
|
||||
let response = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{}/model", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&invalid)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_unchanged(&server, ¤t).await;
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edge_attachments_served_openapi_matches_required_nested_records_and_optional_ends() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let response = server
|
||||
.client
|
||||
.get(server.url("/openapi.json"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let schema: Value = response.json().await.unwrap();
|
||||
let schemas = &schema["components"]["schemas"];
|
||||
let attachment = &schemas["EdgeAttachment"];
|
||||
assert_eq!(attachment["required"], json!(["side", "offset"]));
|
||||
assert_eq!(attachment["additionalProperties"], false);
|
||||
assert_eq!(
|
||||
attachment["properties"]["side"]["$ref"],
|
||||
"#/components/schemas/PortAnchorSide"
|
||||
);
|
||||
assert_eq!(attachment["properties"]["offset"]["minimum"], 0);
|
||||
assert_eq!(attachment["properties"]["offset"]["maximum"], 1);
|
||||
for field in ["source_attachment", "target_attachment"] {
|
||||
assert!(
|
||||
!schemas["EdgeRoute"]["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!(field))
|
||||
);
|
||||
let optional = &schemas["EdgeRoute"]["properties"][field];
|
||||
assert_eq!(optional["default"], Value::Null);
|
||||
assert_eq!(
|
||||
optional["oneOf"],
|
||||
json!([{"$ref":"#/components/schemas/EdgeAttachment"},{"type":"null"}])
|
||||
);
|
||||
}
|
||||
let compiled = compile_bundle(&source_bundle(), ProjectLimits::default()).unwrap();
|
||||
let diagram = id(&compiled.decoded.project, "view");
|
||||
let mut route = compiled.decoded.project.diagrams[&diagram].routes[0].clone();
|
||||
let omitted = serde_json::to_value(&route).unwrap();
|
||||
assert!(omitted.get("source_attachment").is_none());
|
||||
assert!(omitted.get("target_attachment").is_none());
|
||||
route.target_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.0,
|
||||
});
|
||||
let encoded = serde_json::to_value(Command::SetEdgeRoute { diagram, route }).unwrap();
|
||||
assert_eq!(
|
||||
encoded["SetEdgeRoute"]["route"]["target_attachment"],
|
||||
json!({"side":"Right","offset":0.0})
|
||||
);
|
||||
server.stop().await;
|
||||
}
|
||||
+28
-1
@@ -2,7 +2,7 @@
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="generator" content="Docutils 0.22.2: https://docutils.sourceforge.io/" />
|
||||
<meta name="generator" content="Docutils 0.23: https://docutils.sourceforge.io/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Arcadia Studio model REST API</title>
|
||||
<style type="text/css">
|
||||
@@ -947,6 +947,33 @@ authored files and revision. JSON/source imports may omit <span class="docutils
|
||||
it defaults to <span class="docutils literal">[]</span> and empty lists are omitted on export. The normative
|
||||
diagram semantics and source spelling are in <span class="docutils literal">language.rst</span> section 5.</p>
|
||||
</section>
|
||||
<section id="diagram-connection-attachments">
|
||||
<h2>Diagram connection attachments</h2>
|
||||
<p><span class="docutils literal">EdgeRoute.source_attachment</span> and <span class="docutils literal">target_attachment</span> are optional
|
||||
<span class="docutils literal"><span class="pre">{"side":"Bottom","offset":0.25}</span></span> records. Side and a finite offset in
|
||||
<span class="docutils literal">[0,1]</span> are required within a supplied record; unknown fields are rejected.
|
||||
Absence/null means automatic placement. Omitted fields in older schema-3
|
||||
models retain that meaning. The normal transaction endpoint accepts:</p>
|
||||
<pre class="literal-block">{"commands":[{"SetEdgeRoute":{
|
||||
"diagram":"<diagram UUID>",
|
||||
"route":{"edge":"<relation or trace UUID>",
|
||||
"source_attachment":{"side":"Bottom","offset":0.25},
|
||||
"waypoints":[],"label":null}
|
||||
}}]}</pre>
|
||||
<p><span class="docutils literal">SetEdgeRoute</span> replaces the complete route entry. Clients changing one end
|
||||
must retain its other attachment, bends and label; omit/null only the intended
|
||||
attachment to restore that end's automatic position. <span class="docutils literal">RemoveEdgeRoute</span> resets
|
||||
all presentation for that edge. Both support ETag checking, dry runs, atomic
|
||||
validation and source-project writeback. Invalid offsets reject the whole
|
||||
transaction without changing files or revision.</p>
|
||||
<p>These are per-view presentation preferences, not a semantic reconnect. Fractions
|
||||
follow the projected shape, including moves/resizes; semantic reconnect retains
|
||||
them as preferences of the same edge-end slots. Real semantic ports take
|
||||
precedence, and promoted folded endpoints suspend authored routing. The complete
|
||||
boundary mapping, lifecycle and generated-view limitations are normative in
|
||||
<span class="docutils literal">language.rst</span> section 5. GET model/source, PUT source, desktop and transactions
|
||||
share this representation; no separate attachment endpoint exists.</p>
|
||||
</section>
|
||||
<section id="contextual-process-and-chain-topology">
|
||||
<h2>Contextual process and chain topology</h2>
|
||||
<p>Operational processes and system functional chains can combine work
|
||||
|
||||
@@ -329,6 +329,37 @@ authored files and revision. JSON/source imports may omit ``port_anchors``;
|
||||
it defaults to ``[]`` and empty lists are omitted on export. The normative
|
||||
diagram semantics and source spelling are in ``language.rst`` section 5.
|
||||
|
||||
Diagram connection attachments
|
||||
-------------------------------
|
||||
|
||||
``EdgeRoute.source_attachment`` and ``target_attachment`` are optional
|
||||
``{"side":"Bottom","offset":0.25}`` records. Side and a finite offset in
|
||||
``[0,1]`` are required within a supplied record; unknown fields are rejected.
|
||||
Absence/null means automatic placement. Omitted fields in older schema-3
|
||||
models retain that meaning. The normal transaction endpoint accepts::
|
||||
|
||||
{"commands":[{"SetEdgeRoute":{
|
||||
"diagram":"<diagram UUID>",
|
||||
"route":{"edge":"<relation or trace UUID>",
|
||||
"source_attachment":{"side":"Bottom","offset":0.25},
|
||||
"waypoints":[],"label":null}
|
||||
}}]}
|
||||
|
||||
``SetEdgeRoute`` replaces the complete route entry. Clients changing one end
|
||||
must retain its other attachment, bends and label; omit/null only the intended
|
||||
attachment to restore that end's automatic position. ``RemoveEdgeRoute`` resets
|
||||
all presentation for that edge. Both support ETag checking, dry runs, atomic
|
||||
validation and source-project writeback. Invalid offsets reject the whole
|
||||
transaction without changing files or revision.
|
||||
|
||||
These are per-view presentation preferences, not a semantic reconnect. Fractions
|
||||
follow the projected shape, including moves/resizes; semantic reconnect retains
|
||||
them as preferences of the same edge-end slots. Real semantic ports take
|
||||
precedence, and promoted folded endpoints suspend authored routing. The complete
|
||||
boundary mapping, lifecycle and generated-view limitations are normative in
|
||||
``language.rst`` section 5. GET model/source, PUT source, desktop and transactions
|
||||
share this representation; no separate attachment endpoint exists.
|
||||
|
||||
Contextual process and chain topology
|
||||
-------------------------------------
|
||||
|
||||
|
||||
+45
-2
@@ -1785,14 +1785,16 @@ the Reparent command. Moving, resizing, arranging, or folding an owner retains
|
||||
its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
|
||||
<span class="docutils literal">port_anchors</span>; their explicit migration supplies an empty list.</p>
|
||||
<p>Each <span class="docutils literal">routes</span> entry is an EdgeRoute record with a required <span class="docutils literal">edge</span> reference
|
||||
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), and <span class="docutils literal">label</span>
|
||||
(default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
|
||||
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), <span class="docutils literal">label</span>
|
||||
(default <span class="docutils literal">none</span>), and <span class="docutils literal">source_attachment</span> / <span class="docutils literal">target_attachment</span> (each
|
||||
default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
|
||||
at most once in a diagram's route list; different diagrams MAY store independent
|
||||
routes for the same edge. List order and explicit empty entries are retained by
|
||||
source and JSON round trips, although list order has no routing meaning. Edges
|
||||
need not currently be visible in their diagram.</p>
|
||||
<pre class="code text literal-block"><code>routes [
|
||||
{ edge @report_exchange;
|
||||
source_attachment { side Bottom; offset 0.25; };
|
||||
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
|
||||
label { fraction 0.55; offset { x 0; y -16; }; };
|
||||
}
|
||||
@@ -1809,6 +1811,47 @@ are structural errors. A pinned point that obstructs routing remains stored;
|
||||
geometric routing difficulty is reported separately from structural validity.
|
||||
Each EdgeRoute counts as one record against <span class="docutils literal">ParseLimits.max_records</span>; pinned
|
||||
points are bounded by the per-route limit and aggregate input byte limits.</p>
|
||||
<p>An attachment is an EdgeAttachment record with required <span class="docutils literal">side</span> (Left, Right,
|
||||
Top, Bottom) and required finite binary32 <span class="docutils literal">offset</span> from zero through one.
|
||||
Unknown fields, omitted nested fields and out-of-range offsets are structural
|
||||
errors (<span class="docutils literal">invalid_attachment_offset</span> for the latter). Attachments are bounded
|
||||
inline values, at most two per route, not additional graph objects or records.
|
||||
JSON uses the same field names, enum strings and number values. Omitted or null
|
||||
attachments mean automatic positioning; canonical compact source and JSON omit
|
||||
absent attachments. Earlier schema-3 input hydrates both fields as absent.</p>
|
||||
<p>The end names designate stored source/target slots, even for undirected edges.
|
||||
These values MUST NOT change semantic endpoints, ports, ownership, type,
|
||||
orientation or identity. Each preference belongs to that edge end in that view:
|
||||
another view remains independent. It follows the current endpoint's projected
|
||||
boundary under movement, resize or semantic reconnect; reconnect preserves the
|
||||
preference, as it preserves authored bends. The fraction does not identify the
|
||||
previous semantic endpoint. Deletion/reidentification of the edge follows the
|
||||
existing whole-route rules; moving, hiding or replacing its placement retains
|
||||
the preference. Removing one attachment restores that end's automatic position
|
||||
without changing bends, labels or the opposite attachment.</p>
|
||||
<p>For Left/Right, offset gives the Y fraction of the complete projected bounding
|
||||
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
|
||||
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
|
||||
their width/height; Regions and fork/join bars are rectangles. State circles use
|
||||
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
|
||||
<span class="docutils literal">t = 2 * offset - 1</span> and <span class="docutils literal">D</span> be width for Left/Right or height for Top/Bottom.
|
||||
The inset from the selected bounding side is <span class="docutils literal">D/2 * (1 - <span class="pre">sqrt(1-t*t))</span></span> for an
|
||||
ellipse and <span class="docutils literal">D/2 * abs(t)</span> for a diamond. A Terminate X uses the outermost
|
||||
painted-stroke intersection, inset <span class="docutils literal">D * min(offset, <span class="pre">1-offset)</span></span>. Zero and one
|
||||
are valid; different side/fraction pairs can describe the same painted point.
|
||||
Routing leads extend in the selected cardinal direction beyond the bounding box
|
||||
before turning. Source/API fractions retain their precision; pointer editing
|
||||
snaps fractions to hundredths without changing the stored semantic record.</p>
|
||||
<p>An actual semantic Port remains authoritative, including when projection hides
|
||||
its port marker. An edge attachment on that end is retained but dormant; use
|
||||
the port's own placement anchor to move a port. When folding promotes an endpoint
|
||||
to an ancestor, all authored route data is suspended and resumes on expansion.
|
||||
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
|
||||
tips retain their positions with an external loop and a routing warning; the
|
||||
editor must offer an explicit way to choose either end even when handles overlap.
|
||||
Internal state transitions have no external connection. Process's generated
|
||||
topology and Scenario's lifeline/activation/time-derived tips are not consumers
|
||||
of these diagram route fields; this feature does not redefine their semantics.</p>
|
||||
<p><span class="docutils literal">SetEdgeRoute { diagram, route }</span> upserts an entry by edge identity, retaining
|
||||
the order and contents of unrelated entries. <span class="docutils literal">RemoveEdgeRoute { diagram, edge }</span>
|
||||
removes it; removing an absent entry is a no-op for an existing diagram and edge.
|
||||
|
||||
+49
-2
@@ -1167,8 +1167,9 @@ its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
|
||||
``port_anchors``; their explicit migration supplies an empty list.
|
||||
|
||||
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
|
||||
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
|
||||
(default ``none``). Route records have no separate UUID or alias. An edge occurs
|
||||
to an existing Relation or Trace, ``waypoints`` (default ``[]``), ``label``
|
||||
(default ``none``), and ``source_attachment`` / ``target_attachment`` (each
|
||||
default ``none``). Route records have no separate UUID or alias. An edge occurs
|
||||
at most once in a diagram's route list; different diagrams MAY store independent
|
||||
routes for the same edge. List order and explicit empty entries are retained by
|
||||
source and JSON round trips, although list order has no routing meaning. Edges
|
||||
@@ -1178,6 +1179,7 @@ need not currently be visible in their diagram.
|
||||
|
||||
routes [
|
||||
{ edge @report_exchange;
|
||||
source_attachment { side Bottom; offset 0.25; };
|
||||
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
|
||||
label { fraction 0.55; offset { x 0; y -16; }; };
|
||||
}
|
||||
@@ -1196,6 +1198,51 @@ geometric routing difficulty is reported separately from structural validity.
|
||||
Each EdgeRoute counts as one record against ``ParseLimits.max_records``; pinned
|
||||
points are bounded by the per-route limit and aggregate input byte limits.
|
||||
|
||||
An attachment is an EdgeAttachment record with required ``side`` (Left, Right,
|
||||
Top, Bottom) and required finite binary32 ``offset`` from zero through one.
|
||||
Unknown fields, omitted nested fields and out-of-range offsets are structural
|
||||
errors (``invalid_attachment_offset`` for the latter). Attachments are bounded
|
||||
inline values, at most two per route, not additional graph objects or records.
|
||||
JSON uses the same field names, enum strings and number values. Omitted or null
|
||||
attachments mean automatic positioning; canonical compact source and JSON omit
|
||||
absent attachments. Earlier schema-3 input hydrates both fields as absent.
|
||||
|
||||
The end names designate stored source/target slots, even for undirected edges.
|
||||
These values MUST NOT change semantic endpoints, ports, ownership, type,
|
||||
orientation or identity. Each preference belongs to that edge end in that view:
|
||||
another view remains independent. It follows the current endpoint's projected
|
||||
boundary under movement, resize or semantic reconnect; reconnect preserves the
|
||||
preference, as it preserves authored bends. The fraction does not identify the
|
||||
previous semantic endpoint. Deletion/reidentification of the edge follows the
|
||||
existing whole-route rules; moving, hiding or replacing its placement retains
|
||||
the preference. Removing one attachment restores that end's automatic position
|
||||
without changing bends, labels or the opposite attachment.
|
||||
|
||||
For Left/Right, offset gives the Y fraction of the complete projected bounding
|
||||
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
|
||||
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
|
||||
their width/height; Regions and fork/join bars are rectangles. State circles use
|
||||
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
|
||||
``t = 2 * offset - 1`` and ``D`` be width for Left/Right or height for Top/Bottom.
|
||||
The inset from the selected bounding side is ``D/2 * (1 - sqrt(1-t*t))`` for an
|
||||
ellipse and ``D/2 * abs(t)`` for a diamond. A Terminate X uses the outermost
|
||||
painted-stroke intersection, inset ``D * min(offset, 1-offset)``. Zero and one
|
||||
are valid; different side/fraction pairs can describe the same painted point.
|
||||
Routing leads extend in the selected cardinal direction beyond the bounding box
|
||||
before turning. Source/API fractions retain their precision; pointer editing
|
||||
snaps fractions to hundredths without changing the stored semantic record.
|
||||
|
||||
An actual semantic Port remains authoritative, including when projection hides
|
||||
its port marker. An edge attachment on that end is retained but dormant; use
|
||||
the port's own placement anchor to move a port. When folding promotes an endpoint
|
||||
to an ancestor, all authored route data is suspended and resumes on expansion.
|
||||
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
|
||||
tips retain their positions with an external loop and a routing warning; the
|
||||
editor must offer an explicit way to choose either end even when handles overlap.
|
||||
Internal state transitions have no external connection. Process's generated
|
||||
topology and Scenario's lifeline/activation/time-derived tips are not consumers
|
||||
of these diagram route fields; this feature does not redefine their semantics.
|
||||
|
||||
``SetEdgeRoute { diagram, route }`` upserts an entry by edge identity, retaining
|
||||
the order and contents of unrelated entries. ``RemoveEdgeRoute { diagram, edge }``
|
||||
removes it; removing an absent entry is a no-op for an existing diagram and edge.
|
||||
|
||||
+45
-1
@@ -2137,15 +2137,59 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EdgeAttachment": {
|
||||
"type": "object",
|
||||
"description": "Per-view preference on one stored connection end, not a semantic endpoint. Offset is finite and measured along the complete projected bounding-box side. Real semantic ports take precedence; hidden/promoted geometry retains the preference. Reconnect preserves the preference on its source/target slot.",
|
||||
"properties": {
|
||||
"side": {
|
||||
"$ref": "#/components/schemas/PortAnchorSide"
|
||||
},
|
||||
"offset": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Left/Right: top-to-bottom fraction. Top/Bottom: left-to-right fraction. Shape-aware geometry derives the actual contour point."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"side",
|
||||
"offset"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EdgeRoute": {
|
||||
"type": "object",
|
||||
"description": "One diagram's authored route for an existing Relation or Trace, including currently hidden edges. No separate identity. Only pinned points are stored; explicit empty routes are retained.",
|
||||
"description": "One diagram's authored presentation for an existing Relation or Trace, including currently hidden edges. No separate identity. Stores optional endpoint preferences, pinned points and label placement; explicit empty routes are retained. Removing the route resets all authored presentation.",
|
||||
"properties": {
|
||||
"edge": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Existing Relation or Trace identity, never a node placement."
|
||||
},
|
||||
"source_attachment": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EdgeAttachment"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
|
||||
},
|
||||
"target_attachment": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EdgeAttachment"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
|
||||
},
|
||||
"waypoints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -456,3 +456,12 @@ Missing global and scoped parameter IDs remain explicit stabilization metadata,
|
||||
not new random identities assigned on each parse. Public source8 integration must
|
||||
thread the same accounting through the outer project and included files; this
|
||||
private checkpoint does not itself admit a complete version8 source bundle.
|
||||
|
||||
## Connection-end presentation preferences — 2026-09-22
|
||||
|
||||
Issue #33 uses optional side/fraction attachments on each view's `EdgeRoute`,
|
||||
with semantic Port precedence and shared shape-aware routing. Visual attachment
|
||||
editing remains separate from semantic reconnect. The [decision and evidence
|
||||
map](edge-attachments-20260922.md) records alternatives, lifecycle rules,
|
||||
independent falsifiers, evaluators and the Process/Scenario applicability limits.
|
||||
This is branch-specific work; it does not establish an integrated release.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Direct Allocation drawing — issue 30
|
||||
|
||||
Branch-specific design above `17f1f89fef4a6bf520f4b09a2116548b967d030e`,
|
||||
on `codex/allocation-drawing-ytr83r-20260923`.
|
||||
[Issue 30](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/30)
|
||||
holds live scope and acceptance. This note records the intended interaction and
|
||||
verification criteria, not completed tests, native acceptance or integration.
|
||||
|
||||
The user reports that Allocation cannot be drawn from the tools menu in
|
||||
Containment. Core semantics and the existing relationship form already support
|
||||
the relationship there; the direct canvas tool is the missing affordance.
|
||||
Adding that tool follows the same source/target workflow as neighboring tools.
|
||||
A form shortcut alone would still require a dropdown instead of clicking the
|
||||
visible endpoints, leaving the reported drawing task unresolved.
|
||||
|
||||
## Interaction and scope
|
||||
|
||||
Choose Allocation in the diagram palette, click any visible eligible performer,
|
||||
then click compatible unallocated leaf work in the same semantic layer as that
|
||||
performer. The performer's layer need not match a mixed traceability view's owner
|
||||
layer. OA uses an Entity or Actor and an
|
||||
Activity; SA uses a System or Actor and a Function. Highlighting and the current
|
||||
source identify the proposed semantic direction: performer to work, independent
|
||||
of where their cards are drawn. Escape cancels the unfinished connection.
|
||||
The existing Create relationship form remains an alternative for explicit
|
||||
relationship entry; no new Activity or Function is created by this tool.
|
||||
|
||||
Projection eligibility requires support for both performer and work cards:
|
||||
Containment, All relationships, OA ↔ SA traceability and Architecture · allocated
|
||||
work offer Allocation; Functional dataflow, Missions & capabilities and dedicated
|
||||
State-machine views do not. Containment and All relationships can display the resulting Allocation line
|
||||
when its placements and filters permit. Architecture · allocated work represents
|
||||
the same responsibility through nesting, not an additional Allocation arrow.
|
||||
OA ↔ SA traceability can contain both layers, but Allocation endpoints must
|
||||
still belong to the same layer as each other. Selecting an opposite-layer source
|
||||
retains the captured mixed diagram and prior application perspective; cancellation
|
||||
also preserves that perspective. After successful creation, the ordinary model
|
||||
synchronization sets the application perspective to the captured diagram's owner
|
||||
layer while keeping that diagram open. A projection that omits one endpoint category
|
||||
must not offer an unusable drawing workflow. Existing filters remain unchanged;
|
||||
successful creation does not promise that a filtered line is visible.
|
||||
|
||||
## Preservation rules
|
||||
|
||||
- Allocate only eligible leaf work with no existing direct performer. Reject
|
||||
duplicate, occupied, composite, cross-layer and Port endpoints; do not treat
|
||||
drawing a new relation as permission to reassign existing work.
|
||||
- Core validation is authoritative at completion, in addition to candidate
|
||||
highlighting. Candidates must be actual projected endpoints, not hidden
|
||||
children or computed parent summaries.
|
||||
- Create one Allocation in one document transaction. Preserve semantic parents,
|
||||
elements, other relationships, traces and every saved placement, route and
|
||||
view option. Derived projection geometry may change because responsibility
|
||||
changed; that is distinct from rewriting stored layout.
|
||||
- Arming, choosing only a source, refusal and cancellation do not alter model
|
||||
history. Stale document/revision/diagram, navigation and pending property or
|
||||
source edits must prevent delayed completion without discarding those drafts.
|
||||
- Moving cards in Containment remains ordinary arrangement. Assignment-changing
|
||||
drag and Arrange next drag retain their existing allocated-work-view scope.
|
||||
|
||||
No general relationship browser, new reassignment policy, port generation or
|
||||
change to ownership semantics is introduced. The existing connection lifecycle
|
||||
and ordinary validated command boundary are preferred over a parallel gesture
|
||||
framework. Broader form-only relationship kinds are outside this bounded fix.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Exercise real connection events for valid OA and SA endpoint pairs, rejected
|
||||
endpoint classes, cancel/retry, hidden endpoints, stale captures and pending
|
||||
drafts. Compare the complete Project so the only semantic difference is the new
|
||||
Allocation; bind its fresh identity explicitly. Check exact source Undo/Redo
|
||||
and unchanged stored reference-view records. Verify projection eligibility for
|
||||
the new tool without regressing the existing four connection tools.
|
||||
|
||||
In the actual application, find Allocation in Containment's palette, choose a
|
||||
performer and existing leaf work directly, inspect the resulting relation,
|
||||
Undo/Redo, Save, close normally and reopen. Confirm feedback explains nesting
|
||||
when using the allocated-work projection, rather than promising a new line.
|
||||
Inspect nested-card hit selection and filtered-result feedback. Native evidence
|
||||
must distinguish observed interactions from automated coverage and bind source,
|
||||
binary, process and predeclared full-Project expectations. No successful result
|
||||
is asserted here. Linux remains user-paused; combined release acceptance belongs
|
||||
to the integration owner.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Connection attachment editing — issue 33
|
||||
|
||||
Branch-specific work from `3f9e6a18af96b5cd10940da4093be8da28b9c835` on
|
||||
`codex/edge-anchors-ytr83r-20260922`, owned by Codex-ytr83r/root.
|
||||
[Live scope and handoff](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/33)
|
||||
remain in Gitea. This is not an integrated release checkpoint.
|
||||
|
||||
The user needs to move where a connection touches an element without changing
|
||||
the relationship. The existing ordinary route editor offered bends and labels;
|
||||
unported ends were selected from four side midpoints. SA semantic ports already
|
||||
had per-placement anchors. State endpoint handles instead performed semantic
|
||||
reconnect. These are different operations and remain distinct.
|
||||
|
||||
## Decision and alternatives
|
||||
|
||||
Store optional `source_attachment` and `target_attachment` values on `EdgeRoute`.
|
||||
Each contains a side and normalized fraction, interpreted against the actual
|
||||
projected shape. No additional semantic object, ID or reference is introduced.
|
||||
The precise format, geometry, Port precedence, folding, reconnect and reset rules
|
||||
are in [the normative language contract](../docs/language.rst); REST uses the
|
||||
same data and existing `SetEdgeRoute` transaction.
|
||||
|
||||
Compared alternatives:
|
||||
|
||||
- Absolute endpoint coordinates are easy to store but become detached from the
|
||||
owner after movement/resizing and duplicate derived geometry.
|
||||
- Reusing semantic Port anchors would invent ports for OA/state relationships
|
||||
and conflate each connection's presentation with a shared port's identity.
|
||||
- Binding an attachment to an additional semantic endpoint/placement reference
|
||||
would preserve the old owner's identity after reconnect, but needs stale
|
||||
reference cleanup and a dormant preference lifecycle without a current user
|
||||
need. The selected rule retains the edge-end preference across reconnect,
|
||||
just as authored bends are retained; it is documented, not inferred silently.
|
||||
|
||||
Fractions remain precise in source/API; pointer edits snap to hundredths.
|
||||
Automatic absence and authored values are distinct. Press/release without a
|
||||
drag, Escape, stale context, focus/camera/layout change and outside release do
|
||||
not author a preference. Returning within three screen pixels of the press
|
||||
restores the exact initial optional value, including its original precision.
|
||||
Successful editing changes one field in one route in one view in one history
|
||||
operation. Per-end reset preserves the other end, bends and label.
|
||||
|
||||
Ordinary diagrams expose eligible selected-end handles and explicit source/target
|
||||
menu choice for overlapping handles. State menus expose separately labelled
|
||||
Adjust attachment and Reconnect actions; selecting a transition still retains
|
||||
the existing reconnect gesture. Ports keep their existing movement policy.
|
||||
This work does not adopt or alter the separate #16 resize/port-capacity policy.
|
||||
|
||||
## Review-driven corrections and evaluators
|
||||
|
||||
Independent contract review identified projection-filtered Port markers as an
|
||||
unsafe authority: eligibility and routing now consult semantic endpoint identity.
|
||||
Attachment metadata participates in the route-preview cache key. Inset contours
|
||||
use actual lead lengths for path costs and pin fractions; shape scan-line
|
||||
intersections are shared by routing and pointer gestures. Side choice uses the
|
||||
nearest cardinal bounding segment, avoiding coincident circle scan-line points
|
||||
that otherwise retain the wrong exit direction. Hysteresis is capped by symbol
|
||||
size so small circles can change sides at low zoom; an explicit regression
|
||||
covers midpoint, overshoot and near-corner intent.
|
||||
|
||||
The first frozen gate exposed a remaining overshoot case: distances measured
|
||||
from far outside a small symbol converged, keeping the previous perpendicular
|
||||
side. Measuring side intent at the cursor clamped to the bounding box preserves
|
||||
the intended side under overshoot. The failed gate is retained separately.
|
||||
Valid Port-to-Port Item traces also omit projected port IDs; routing now resolves
|
||||
the semantic port before looking up its visible, owner-qualified marker. An
|
||||
actual trace regression checks moved anchors and unchanged trace metadata.
|
||||
|
||||
Coincident authored self ends previously threatened an empty candidate list.
|
||||
They retain their positions and receive an explicit loop warning. A critic then
|
||||
showed a fixed invented detour point could collide with a neighboring card even
|
||||
though the opposite side was clear. Computed detours now consider both directions
|
||||
and multiple bounded extents, preferring clear candidates. These points never
|
||||
become persisted hard pins. Geometry tests exercise blockers on either side.
|
||||
|
||||
Cross-review found ordinary attachment drags survived camera/focus/layout
|
||||
changes and outside release. They now carry a viewport stamp and cancel on those
|
||||
changes, with an actual pointer-event cancellation matrix. The same review
|
||||
reconciled return-to-press behavior across ordinary and State canvases.
|
||||
|
||||
Meaningful evaluators are `oa-core` integration tests `attachment_geometry`,
|
||||
`attachment_lifecycle`, `attachment_trace_ports`, `attachment_workshop`,
|
||||
`edge_attachments` and the existing routing/port/state suites; desktop ordinary
|
||||
attachment and State pointer/planner tests; server `http::edge_attachments`
|
||||
conditional transactions, exact source exports and restart. Tests compare complete
|
||||
projects/source where applicable, not just attachment fields. Native acceptance
|
||||
uses the [fixture and workflow plan](experiments/edge-attachments-20260922/native-plan.md).
|
||||
Final test/native results and exact tested commit belong in the publication
|
||||
handoff, not an unverified claim in this decision record.
|
||||
|
||||
## Applicability and integration limits
|
||||
|
||||
Process topology uses generated cards and qualified contextual addresses.
|
||||
Scenario endpoints derive from lifelines, activations, lifecycle and time/order
|
||||
notation. Neither renderer consumes ordinary `Diagram.routes`; their visual
|
||||
editing requires separate notation-aware contracts. This change does not claim
|
||||
to implement arbitrary attachment dragging there, or to resolve automatic
|
||||
arrangement (#32) or State bend editing (#23).
|
||||
|
||||
The base includes ordinary Text work, not the separately published Scenario
|
||||
spacing/inspector branches or the latest transition lifecycle branch. Integration
|
||||
owner #27 must compose actual pushed commits and verify the combined result.
|
||||
Linux/WSL verification remains paused by the user.
|
||||
@@ -0,0 +1,53 @@
|
||||
// Immutable acceptance baseline: copy before editing. Stable IDs are deliberate.
|
||||
arcadia 7;
|
||||
project endpoint_acceptance "Harbor dispatch — attachment acceptance" id "ea202922-0033-4000-8000-000000000001" {
|
||||
description "Small native fixture for presentation-only endpoint editing. Names describe stored source-to-target direction.";
|
||||
oa_activity receive_report "Receive distress report" id "ea202922-0033-4000-8000-000000000010" { }
|
||||
oa_activity dispatch_crew "Dispatch rescue crew" id "ea202922-0033-4000-8000-000000000011" { }
|
||||
interaction dispatch_request "Dispatch request: Receive → Dispatch" id "ea202922-0033-4000-8000-000000000020" { source @receive_report; target @dispatch_crew; description "Semantic sentinel: retain this description and direction."; }
|
||||
sa_system controller "Rescue readiness controller" id "ea202922-0033-4000-8000-000000000030" { }
|
||||
sa_state_machine readiness "Rescue readiness" id "ea202922-0033-4000-8000-000000000031" { parent @controller; }
|
||||
sa_region main_region "Readiness region" id "ea202922-0033-4000-8000-000000000032" { parent @readiness; }
|
||||
sa_initial start "Startup" id "ea202922-0033-4000-8000-000000000033" { parent @main_region; }
|
||||
sa_state ready "Ready" id "ea202922-0033-4000-8000-000000000034" { parent @main_region; }
|
||||
sa_state active "Active" id "ea202922-0033-4000-8000-000000000035" { parent @main_region; }
|
||||
sa_choice decision "Dispatch decision" id "ea202922-0033-4000-8000-000000000036" { parent @main_region; }
|
||||
sa_final finished "Shutdown complete" id "ea202922-0033-4000-8000-000000000037" { parent @main_region; }
|
||||
state_transition initialize "Initialize: Startup → Ready" id "ea202922-0033-4000-8000-000000000040" { source @start; target @ready; transition_kind External; triggers []; trigger ""; guard ""; effects []; effect ""; }
|
||||
state_transition activate "Activate: Ready → Active" id "ea202922-0033-4000-8000-000000000041" { source @ready; target @active; transition_kind External; triggers []; trigger ""; guard "crew_assigned"; effects []; effect ""; }
|
||||
state_transition assess "Assess: Active → Decision" id "ea202922-0033-4000-8000-000000000042" { source @active; target @decision; transition_kind External; triggers []; trigger ""; guard ""; effects []; effect ""; }
|
||||
state_transition retry "Retry: Decision → Ready" id "ea202922-0033-4000-8000-000000000043" { source @decision; target @ready; transition_kind External; triggers []; trigger ""; guard "retry_requested"; effects []; effect ""; }
|
||||
state_transition finish "Finish: Decision → Shutdown" id "ea202922-0033-4000-8000-000000000044" { source @decision; target @finished; transition_kind External; triggers []; trigger ""; guard "else"; effects []; effect ""; }
|
||||
state_transition refresh "Refresh: Active → Active" id "ea202922-0033-4000-8000-000000000045" { source @active; target @active; transition_kind External; triggers []; trigger ""; guard "refresh_requested"; effects []; effect ""; }
|
||||
state_transition internal_check "Internal status check" id "ea202922-0033-4000-8000-000000000046" { source @active; target @active; transition_kind Internal; triggers []; trigger ""; guard ""; effects []; effect "Status unchanged"; }
|
||||
sa_function emit_status "Emit readiness status" id "ea202922-0033-4000-8000-000000000050" { }
|
||||
sa_function display_status "Display readiness status" id "ea202922-0033-4000-8000-000000000051" { }
|
||||
sa_port status_out "Status output" id "ea202922-0033-4000-8000-000000000052" { parent @emit_status; contract { FunctionOutput { outgoing_items []; }; }; }
|
||||
sa_port status_in "Status input" id "ea202922-0033-4000-8000-000000000053" { parent @display_status; contract { FunctionInput { incoming_items []; }; }; }
|
||||
functional_exchange status_exchange "Readiness status: Emit → Display" id "ea202922-0033-4000-8000-000000000054" { source @emit_status; target @display_status; source_port @status_out; target_port @status_in; items []; }
|
||||
diagram dispatch_view "01 Dispatch — edit attachments" id "ea202922-0033-4000-8000-000000000100" { layer Oa; projection FunctionalDataflow; routes [{ edge @dispatch_request; waypoints [{ x 440; y 340; }]; label { fraction 0.5; offset { x 12; y -18; }; }; source_attachment { side Right; offset 0.5; }; target_attachment { side Left; offset 0.6; }; }]; }
|
||||
diagram dispatch_reference "02 Dispatch — untouched reference" id "ea202922-0033-4000-8000-000000000101" { layer Oa; projection FunctionalDataflow; routes [{ edge @dispatch_request; target_attachment { side Bottom; offset 0.25; }; }]; }
|
||||
node dispatch_view_receive id "ea202922-0033-4000-8000-000000000110" { diagram @dispatch_view; element @receive_report; position { x 100; y 180; }; size { width 240; height 120; }; collapsed false; }
|
||||
node dispatch_view_dispatch id "ea202922-0033-4000-8000-000000000111" { diagram @dispatch_view; element @dispatch_crew; position { x 600; y 180; }; size { width 240; height 120; }; collapsed false; }
|
||||
node dispatch_reference_receive id "ea202922-0033-4000-8000-000000000120" { diagram @dispatch_reference; element @receive_report; position { x 100; y 180; }; size { width 240; height 120; }; collapsed false; }
|
||||
node dispatch_reference_dispatch id "ea202922-0033-4000-8000-000000000121" { diagram @dispatch_reference; element @dispatch_crew; position { x 600; y 180; }; size { width 240; height 120; }; collapsed false; }
|
||||
diagram readiness_view "03 Readiness — edit attachments" id "ea202922-0033-4000-8000-000000000200" { layer Sa; projection { StateMachine { machine @readiness; }; }; routes [{ edge @activate; waypoints [{ x 500; y 310; }]; label { fraction 0.5; offset { x 8; y -16; }; }; source_attachment { side Right; offset 0.5; }; target_attachment { side Left; offset 0.6; }; }, { edge @initialize; source_attachment { side Right; offset 0.5; }; }, { edge @finish; source_attachment { side Right; offset 0.5; }; }]; }
|
||||
diagram readiness_reference "04 Readiness — untouched reference" id "ea202922-0033-4000-8000-000000000201" { layer Sa; projection { StateMachine { machine @readiness; }; }; routes []; }
|
||||
node readiness_view_readiness id "ea202922-0033-4000-8000-000000000210" { diagram @readiness_view; element @readiness; position { x 40; y 40; }; size { width 980; height 590; }; collapsed false; }
|
||||
node readiness_view_main_region id "ea202922-0033-4000-8000-000000000211" { diagram @readiness_view; element @main_region; position { x 64; y 104; }; size { width 932; height 500; }; collapsed false; }
|
||||
node readiness_view_start id "ea202922-0033-4000-8000-000000000212" { diagram @readiness_view; element @start; position { x 110; y 252; }; size { width 20; height 20; }; collapsed false; }
|
||||
node readiness_view_ready id "ea202922-0033-4000-8000-000000000213" { diagram @readiness_view; element @ready; position { x 190; y 210; }; size { width 240; height 120; }; collapsed false; }
|
||||
node readiness_view_active id "ea202922-0033-4000-8000-000000000214" { diagram @readiness_view; element @active; position { x 570; y 210; }; size { width 240; height 140; }; collapsed false; }
|
||||
node readiness_view_decision id "ea202922-0033-4000-8000-000000000215" { diagram @readiness_view; element @decision; position { x 670; y 430; }; size { width 40; height 40; }; collapsed false; }
|
||||
node readiness_view_finished id "ea202922-0033-4000-8000-000000000216" { diagram @readiness_view; element @finished; position { x 880; y 440; }; size { width 24; height 24; }; collapsed false; }
|
||||
node readiness_reference_readiness id "ea202922-0033-4000-8000-000000000230" { diagram @readiness_reference; element @readiness; position { x 40; y 40; }; size { width 980; height 590; }; collapsed false; }
|
||||
node readiness_reference_main_region id "ea202922-0033-4000-8000-000000000231" { diagram @readiness_reference; element @main_region; position { x 64; y 104; }; size { width 932; height 500; }; collapsed false; }
|
||||
node readiness_reference_start id "ea202922-0033-4000-8000-000000000232" { diagram @readiness_reference; element @start; position { x 110; y 212; }; size { width 20; height 20; }; collapsed false; }
|
||||
node readiness_reference_ready id "ea202922-0033-4000-8000-000000000233" { diagram @readiness_reference; element @ready; position { x 190; y 170; }; size { width 240; height 120; }; collapsed false; }
|
||||
node readiness_reference_active id "ea202922-0033-4000-8000-000000000234" { diagram @readiness_reference; element @active; position { x 570; y 170; }; size { width 240; height 140; }; collapsed false; }
|
||||
node readiness_reference_decision id "ea202922-0033-4000-8000-000000000235" { diagram @readiness_reference; element @decision; position { x 670; y 430; }; size { width 40; height 40; }; collapsed false; }
|
||||
node readiness_reference_finished id "ea202922-0033-4000-8000-000000000236" { diagram @readiness_reference; element @finished; position { x 880; y 440; }; size { width 24; height 24; }; collapsed false; }
|
||||
diagram ports_view "05 Status — semantic ports regression" id "ea202922-0033-4000-8000-000000000300" { layer Sa; projection FunctionalDataflow; routes []; }
|
||||
node ports_emit id "ea202922-0033-4000-8000-000000000310" { diagram @ports_view; element @emit_status; position { x 100; y 180; }; size { width 240; height 120; }; collapsed false; port_anchors [{ port @status_out; side Right; offset 0.5; }]; }
|
||||
node ports_display id "ea202922-0033-4000-8000-000000000311" { diagram @ports_view; element @display_status; position { x 600; y 180; }; size { width 240; height 120; }; collapsed false; port_anchors [{ port @status_in; side Left; offset 0.5; }]; }
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# Native endpoint attachment acceptance
|
||||
|
||||
This is a prepared plan, not a record of passed native checks. `native-fixture.arcadia` is a deliberately small schema-7 source fixture with 49 explicit stable UUIDs. The parent must parse and validate it with the candidate before staging a working copy. No GUI, compilation, or evaluator has been run for this preparation.
|
||||
|
||||
## Bind the run
|
||||
|
||||
Record the exact candidate commit, source/worktree cleanliness, GUI/runtime hashes, successful gate receipts, fixture hash and recipe hash. Keep an immutable baseline and a separate mutable working source; use an isolated profile. Record launch, normal close, stdout/stderr, saved checkpoints, and captures. Compile baseline and checkpoints to full typed Project values with the same pinned source evaluator. Use a baseline-derived expected Project with only the named field changes; compare the entire Project, not a JSON subset or screenshot. Source text formatting may differ after save; source must parse to the exact expected Project. Save/reopen comparisons must use the same expected Project. Preserve raw files and integer precision.
|
||||
|
||||
The fixture contains two OA activities and their one interaction, one small SA state machine, and a separate two-function semantic-port regression view. Its controller is semantic ownership, not a large canvas placement. The State view places the machine, region, and all five vertices. Two saved views per edited graph expose view-isolation failures. View 05 is separate to keep the main workflows uncluttered.
|
||||
|
||||
## Identities and geometry
|
||||
|
||||
Every ID has prefix `ea202922-0033-4000-8000-`; the twelve-digit suffix below is decimal-padded. The source file is authoritative for all fields.
|
||||
|
||||
| Object / alias | ID suffix | Purpose |
|
||||
|---|---:|---|
|
||||
| `dispatch_request` | 20 | Stored source `receive_report` (10), target `dispatch_crew` (11) |
|
||||
| `dispatch_view` / `dispatch_reference` | 100 / 101 | Edited OA view / untouched OA view |
|
||||
| `readiness` / `main_region` | 31 / 32 | Machine / region membership |
|
||||
| `start`, `ready`, `active`, `decision`, `finished` | 33–37 | Initial, two stable states, choice, final |
|
||||
| `activate` | 41 | Stored Ready source → Active target |
|
||||
| `initialize` / `finish` | 40 / 44 | Circle-source / diamond-source contour checks |
|
||||
| `refresh` / `internal_check` | 45 / 46 | External self-transition / internal row |
|
||||
| `readiness_view` / `readiness_reference` | 200 / 201 | Edited State view / untouched State view |
|
||||
| `status_exchange` / `ports_view` | 54 / 300 | Real semantic ports regression |
|
||||
|
||||
Coordinates below are **diagram coordinates**, not assumed native screen coordinates. After Fit, identify bounds and zoom/pan from actual UI; map `window = canvas origin + pan + zoom × diagram`. State canvas includes a guidance strip in its offset. Confirm painted handles before pressing; retain screenshots documenting the mapping. Source and target describe stored ends, regardless of arrow appearance.
|
||||
|
||||
| View / endpoint | Baseline attachment / contour point | Intended destination |
|
||||
|---|---|---|
|
||||
| OA `dispatch_request` source | Right 0.5 → (340,240) | Top 0.25 → (160,180) |
|
||||
| OA `dispatch_request` target | Left 0.6 → (600,252) | Bottom 0.75 → (780,300) |
|
||||
| State `activate` source | Right 0.5 → (430,270) | Top 0.25 → (250,210) |
|
||||
| State `activate` target | Left 0.6 → (570,294) | Bottom 0.75 → (750,350) |
|
||||
| State `initialize` initial source | Right 0.5 → (126,260) | Top 0.5 → (118,252) |
|
||||
| State `finish` choice source | Right 0.5 → (698,444) | Top 0.5 → (684,430) |
|
||||
| Real `status_out` / `status_in` | Right 0.5 → (340,240) / Left 0.5 → (600,240) | No attachment editing permitted |
|
||||
|
||||
For rounded corners, the quarter-width points are on the straight section. Circle and diamond checks intentionally use contour extrema with unambiguous 0.5 offsets. Native drag input may quantize to adjacent hundredths: if the intended fraction is not attained, do not change the expected oracle to match output. Repeat from the prior checkpoint using the painted geometry, or record the observed limitation and narrow the claim.
|
||||
|
||||
## OA workflow and exact expectations
|
||||
|
||||
1. Open view **01 Dispatch — edit attachments** and Fit. Select `Dispatch request` on its line. Confirm distinct endpoint handles and context actions. Save baseline checkpoint `00` before edits; full Project equals baseline B.
|
||||
2. Drag source handle from (340,240) to (160,180), inspect live preview and release. Save `01`. Expected O1 = B with **only** `diagrams[100].routes[edge20].source_attachment = {Top,0.25}`. Target sentinel `{Left,0.6}`, waypoint `(440,340)`, label `{fraction:0.5,offset:(12,-18)}`, relation description/direction, nodes and all other views remain exact.
|
||||
3. Undo and save `02`: B exactly. Redo and save `03`: O1 exactly. One Undo must remove the entire drag, not a partial movement.
|
||||
4. Drag target to (780,300), save `04`: O2 = O1 with only target `{Bottom,0.75}`. Open source and target context actions to ensure end identity is clear. Use **Reset source attachment**, save `05`: O3 = O2 with source None; preserve target, waypoint and label. Undo returns O2, redo returns O3.
|
||||
5. Start a drag, move away and return within three screen pixels of the press, then release: no source/history change. Repeat a drag canceled with Escape; repeat with right click, middle pan, wheel/camera change, focus loss, and release outside. Save a checkpoint after each meaningful cancellation; expect O3 exactly. Camera-only state is outside Project. After these checks, one Undo still reverses the last real reset (O2), showing no hidden history entry; Redo restores O3.
|
||||
6. Open view **02 Dispatch — untouched reference**: its independent target `{Bottom,0.25}` and placements remain baseline. Return to 01, save, close normally, reopen the saved working file and save `06`: O3 exactly. Confirm the saved source contains the attachment and existing waypoint/label.
|
||||
|
||||
## State workflow and exact expectations
|
||||
|
||||
Continue from saved O3 so the State trial also proves preservation of the prior OA edits. The frozen expected catalog composes State changes on O3; B remains the unchanged comparison for view 201.
|
||||
|
||||
1. Open **Rescue readiness** in Behavior / State diagram and choose **03 Readiness — edit attachments**. Check source-defined membership is visible, with Ready and Active readable and no nested hierarchy. Select `Activate`; choose **Adjust source attachment** from its actions, then drag the highlighted source to (250,210). Save `S01`: S1 = O3 with only `diagrams[200].routes[edge41].source_attachment={Top,0.25}`. Preserve target `{Left,0.6}`, waypoint `(500,310)`, label `{fraction:0.5,offset:(8,-16)}`, all semantic transitions and view 201.
|
||||
2. Undo → O3, redo → S1. Adjust target to (750,350), save `S02`: S2 adds only target `{Bottom,0.75}`. Reset target and save `S03`: S3 has target None, preserves source/bend/label. Cancel an attachment gesture with Escape and perform away-return and wheel interruption; checkpoints remain S3. View **04 Readiness — untouched reference** stays B exactly.
|
||||
3. Choose `Initialize` → **Adjust source attachment**, drag the initial circle source to (118,252), save `S04`: S4 = S3 with only route edge40 source `{Top,0.5}`. Choose `Finish` → Adjust source, drag the choice source to (684,430), save `S05`: S5 = S4 with only route edge44 source `{Top,0.5}`. Capture both painted contours; the typed checkpoint is the persistence oracle. Undo/redo each as practical. Save/normal-close/reopen final checkpoint: S5 exactly.
|
||||
4. Inspect external `Refresh: Active → Active`: both Adjust source and Adjust target must be discoverable and identify the corresponding end even on a self-transition. Inspect `Internal status check` row: it must not offer external attachment handles/actions. Do not infer every symbol was exercised from these checks.
|
||||
5. **Separate reversible reconnect trial from S5:** with Select / move, drag the selected `Activate` target handle from Active to `Dispatch decision` (or use explicitly labelled Reconnect target). Expected R1 differs from S5 only in relation41's semantic target: 35 → 36. Route presentation fields and every other relation field remain unchanged. Undo must restore S5. This distinguishes semantic reconnect from Adjust; do not mix its checkpoint with S5. If cardinality/validity blocks the trial, record it and do not claim reconnect success without a valid alternative approved against source rules.
|
||||
|
||||
## Real semantic ports regression
|
||||
|
||||
Open **05 Status — semantic ports regression**. Select the `Readiness status` functional exchange. Both attachment Move/Reset actions must be disabled or absent because source_port52 and target_port53 remain authoritative. Drag Status output to its owner's Top/0.25 position through the established port-layout interaction, then Undo. That trial may change only the emitting node's port_anchor and must restore S5 exactly on Undo; relation/source_port/target_port identities must never change. No edge-attachment record should be created by port dragging. Record UI hit selection and full saved Project, then leave this trial out of the final OA/State checkpoint.
|
||||
|
||||
## Acceptance and limits
|
||||
|
||||
Require exact full-Project checks for every named checkpoint, normal-close/reopen retention, source/target discoverability, useful previews, and cancellation without hidden history. Inspect native fit/readability and any port/label/handle overlap; reduce claim or fix defects rather than interpreting screenshots as model proof. This fixture does not establish Process/Scenario authoring support, all state symbols, every projection, promoted/folded endpoints, continuous held-motion performance, or exact camera persistence. Those require separate evidence; targeted automated tests remain complementary. Runtime source parsing/validation and native execution are pending parent gates.
|
||||
|
||||
### Prepared layout clearance correction
|
||||
|
||||
The edited State view places Ready and Active at y=210 and Startup at y=252. The reference view intentionally retains its earlier positions. Region header clearance reaches y=180 (104 + 64 + 12); the planned Ready top lead now ends at y=192 (210 - 18), above the card but below the header obstacle. This changes only the three edited-view placements; the manual waypoint (500,310), both route labels, semantic objects, and reference view are preserved. `crates/oa-core/tests/attachment_workshop.rs` validates the fixture and routes the named edited edges at every planned checkpoint. Test execution is pending the parent gate; no clear-path claim is made from static inspection alone.
|
||||
@@ -0,0 +1,90 @@
|
||||
# State transition bend editing — issue 23
|
||||
|
||||
Branch-specific work above attachment source `53d01a3`, on
|
||||
`codex/state-bends-ytr83r-20260923`.
|
||||
[Issue 23](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/23)
|
||||
owns live scope and acceptance. This document records the design; it does not
|
||||
assert completed verification or integration.
|
||||
|
||||
The user asks to edit bends in State-machine connections as in architecture
|
||||
diagrams. State already renders `EdgeRoute.waypoints`; its missing capability is
|
||||
interactive authoring. Bends are positions in one saved diagram. They neither
|
||||
change transition endpoints/meaning nor belong to every view of the transition.
|
||||
|
||||
## Interaction decision
|
||||
|
||||
Use direct manipulation of an already selected transition: drag an exposed line
|
||||
segment to insert one bend, or drag an authored square handle to move that bend.
|
||||
An initial selection click changes no route. Endpoint circles retain semantic
|
||||
reconnect behavior; the separate attachment action retains its explicit mode.
|
||||
State vertices and action rows retain selection/movement precedence. Clicking
|
||||
a transition label selects it; this task does not introduce label dragging.
|
||||
|
||||
The transition menu offers Add bend here when invoked on an eligible segment,
|
||||
or Add bend at the longest interior segment from Actions. Right-clicking an
|
||||
authored square offers Remove bend. Clear bends removes all authored waypoints
|
||||
in this view while retaining both attachment preferences and the label override.
|
||||
Existing transition Delete behavior stays explicit and separate; this design
|
||||
does not add a pin-specific Delete shortcut.
|
||||
|
||||
Alternatives considered: an Edit bends mode makes the distinction visible but
|
||||
adds another persistent tool and cancellation/focus state; menu-only editing is
|
||||
discoverable but too slow for repeated shaping. Direct dragging plus explicit
|
||||
menu actions matches the existing architecture workflow without another mode.
|
||||
The menu provides a recovery path when a label or endpoint covers a line hit.
|
||||
|
||||
## Invariants
|
||||
|
||||
- Only an actual visible, unsuspended routed State transition can be edited.
|
||||
Internal transitions render as rows rather than lines. Folded endpoint proxies
|
||||
suspend authored routing. In both cases existing dormant waypoints survive;
|
||||
the UI disables operations instead of deleting metadata.
|
||||
- Generated elbows, mandatory endpoint leads and computed self-loop detours are
|
||||
routing output, not authored bends. Editing inserts one pin, never the entire
|
||||
computed polyline. The picked rendered-segment index determines insertion
|
||||
among stored pins, including reversed or overlapping traversals.
|
||||
- Movement requires three screen pixels. Dragged pins snap to the existing
|
||||
five-unit grid. A stationary press or return within three screen pixels
|
||||
restores the exact baseline, including imported off-grid coordinates and
|
||||
absence of a stored route. It creates no history entry.
|
||||
- Preview uses the actual routing engine and captured baseline. The model stays
|
||||
unchanged until release. A successful drag replaces only `waypoints` in the
|
||||
current route, leaving attachments, label, relation metadata and other views
|
||||
exact, in one existing `SetEdgeRoute` transaction.
|
||||
- A gesture belongs to its document, diagram, revision, interaction epoch,
|
||||
selected transition and viewport. Escape, pending edits, tool/context changes,
|
||||
camera/bounds changes, focus loss, pointer exit, outside release and a pan/menu
|
||||
interruption cancel it. A canceled preview must not supply menu pin indices.
|
||||
A delayed drag result must not apply after selecting another object.
|
||||
- Finite positions, valid indices and the existing 128-pin limit are checked at
|
||||
the command boundary. Exact no-ops are suppressed before execution.
|
||||
|
||||
## Verification plan
|
||||
|
||||
Use real canvas event tests at multiple zooms for insertion/movement, ordered
|
||||
cursor input, cancellation, exact return-to-press, preview invalidation and menu
|
||||
targets. Planner/app tests compare entire Projects and source bundles across
|
||||
Undo/Redo, preserve attachments/label/reference views, and refuse internal,
|
||||
folded, nonfinite, stale, pending and capacity-invalid operations. Exercise
|
||||
right-click during a provisional insertion and selection changes before queued
|
||||
commit admission. Retain existing attachment/reconnect and focused Delete tests.
|
||||
|
||||
Then exercise the actual Windows application: add, move, remove and clear bends;
|
||||
check unchanged attachments and independent views; Save/Undo/Redo and reopen;
|
||||
observe menus, guidance, preview and normal close. Bind evidence to an identified
|
||||
source/binary and predeclared complete-Project expectations. Compilation alone
|
||||
does not establish interaction acceptance. Linux remains user-paused.
|
||||
|
||||
The first full workspace run exposed a read-only pan regression: canceling model
|
||||
edits on every pending-draft event also canceled the middle-button pan. Keep
|
||||
edit cancellation separate from navigation cancellation; the existing scoped
|
||||
pan regression exercises this distinction. The self-loop test also distinguishes
|
||||
typed-model restoration by Clear from byte-exact source restoration by Undo:
|
||||
source-preserving edits may retain harmless empty-list whitespace, whereas Undo
|
||||
must recover the original source bundle exactly. Failed run receipts are retained;
|
||||
these findings do not assert that the corrected candidate has passed its gates.
|
||||
|
||||
Codex-956atf retains issue 16 resize and issue 27 integration ownership. This
|
||||
work uses the existing State model/router and needs composition with those
|
||||
contributions. It does not certify a combined release, change Process/Scenario
|
||||
notation, redesign automatic arrangement or adopt a new routing schema.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Traceability view comprehension — issue 31
|
||||
|
||||
Branch-specific work above `b8685416f56e53e30bd11ecf93a54cea10ac8458`.
|
||||
[Issue31](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/31)
|
||||
owns live progress. This note records the chosen design and acceptance plan,
|
||||
not completed validation or integration.
|
||||
|
||||
The user's observed problem is that OA↔SA traceability looks like All
|
||||
relationships and its purpose is unclear. Existing core behavior already
|
||||
projects both layers' placed elements, ordinary relationships and explicit
|
||||
traces with available endpoints. Switching projection neither creates semantic
|
||||
counterparts nor adds missing placements. Existing Add existing supports both
|
||||
layers, but its default suggested-type filter conceals opposite-layer choices.
|
||||
Show in this diagram also has inconsistent cross-layer eligibility and selects
|
||||
an opposite-layer element before placement, potentially changing the target view.
|
||||
|
||||
## Decision and criteria
|
||||
|
||||
Make the existing workflow understandable and reachable before introducing a
|
||||
second counterpart browser. Criteria: a new user can explain the distinction,
|
||||
find both named ends, place existing counterparts in the chosen saved diagram,
|
||||
see/inspect correspondence, understand absent lines, and preserve semantic data.
|
||||
Keep the established mixed ordinary/trace contract, explicit semantic creation,
|
||||
saved view filters and single-operation placement history.
|
||||
|
||||
Use concise projection-local explanation, the actual count of projected trace
|
||||
edges, Add existing elements, Review layer traces, and existing View visibility.
|
||||
Default the Existing chooser to All compatible types only for Traceability;
|
||||
creation and ordinary-view defaults stay scoped to their existing contexts.
|
||||
Align Show in this diagram with actual supported placement and retain the
|
||||
captured diagram across cross-layer selection. Review layer traces opens a
|
||||
predictable layer-trace inventory without a stale artifact filter/search, through
|
||||
existing navigation/draft guards. It does not promise that subsequently choosing
|
||||
an opposite-layer endpoint preserves the originating diagram.
|
||||
|
||||
The count is exactly `project_view(...).edges.filter(trace).count()`, after saved
|
||||
filters. It is neither a project-wide trace total nor a coverage denominator.
|
||||
No overlapping missing/hidden reasons are added into invented totals. Port
|
||||
correspondences use owner-card placements; relation-to-relation correspondences
|
||||
remain reviewable semantic records and are not promised a diagram line.
|
||||
|
||||
An expandable view-local counterpart browser with named Place/Locate actions
|
||||
was independently proposed. It can reduce name memorization, but introduces
|
||||
another scope, owner-mapping, omission-reason and targeted-placement interface.
|
||||
The existing chooser already completes the bounded placement task once its
|
||||
default and shortcut are corrected. Revisit the richer browser if the actual
|
||||
native task remains confusing. Tooltip-only, traces-only replacement, automatic
|
||||
counterpart creation/placement and forced OA/SA layout were rejected: respectively
|
||||
weak task completion, broken mixed-context contract, surprising semantics/history,
|
||||
and unwanted authored-layout changes.
|
||||
|
||||
## Verification plan
|
||||
|
||||
Exercise the real Existing chooser and actual Show menu path for opposite-layer
|
||||
elements, including stale/pending refusal and ordinary-view restrictions. Only
|
||||
the current diagram's intended placement may change; preserve semantic maps,
|
||||
other views, route/visibility choices, exact source Undo/Redo and save/reopen.
|
||||
Ports cannot become node placements. An already placed element must not acquire
|
||||
a duplicate placement. Predictable trace review must preserve drafts and obey
|
||||
the usual modal/navigation guards.
|
||||
|
||||
Use core projection/filter evidence for no trace, missing endpoints, both placed,
|
||||
ordinary OA/SA edges, multiple traces, explicit hides plus disabled Trace kind,
|
||||
port owners and relation correspondence. Do not infer visible lines from
|
||||
unfiltered edge_availability or confuse unrelated model traces with counterparts.
|
||||
|
||||
Native acceptance starts with an OA-owned mixed view, one already visible
|
||||
correspondence pair and another existing named SA counterpart not yet placed.
|
||||
Find/place it through the visible controls, see/select the additional trace,
|
||||
inspect an absent-line visibility reason, Undo/Redo and Save/reopen. Inspect
|
||||
toolbar/canvas space and chooser clarity at a realistic window size. Different
|
||||
help text or count tests alone do not resolve the user's comprehension complaint.
|
||||
Linux remains user-paused; Codex-956atf retains shared integration ownership.
|
||||
Reference in New Issue
Block a user