Preserve canvas context across property edits and contextual drawing

This commit is contained in:
2026-10-01 22:49:47 -07:00
parent 6520a89dc0
commit c7065db76e
8 changed files with 1205 additions and 23 deletions
@@ -0,0 +1,304 @@
//! Populated presentation survives mixed-layer projection switches.
//! Complements projection_switching.rs without external fixture dependencies.
use oa_core::{
projection::*, routing::route_diagram, source_project::*, view_options::ViewIndex, *,
};
struct Populated {
project: Project,
diagram: Id,
groups: [Id; 2],
children: [[Id; 2]; 2],
edges: [Id; 2],
ports: [Id; 2],
trace: Id,
}
fn xy(x: f32, y: f32) -> Position {
Position { x, y }
}
fn apply(project: &mut Project, command: Command) {
*project = prepare_command(project, &command).unwrap().project;
}
fn fixture(layer: Layer, collapsed: bool) -> Populated {
let mut p = Project::empty();
let oa_group = p
.add_element(Kind::Activity, Layer::Oa, "Operational response", None)
.unwrap();
let oa_send = p
.add_element(Kind::Activity, Layer::Oa, "Request repair", Some(oa_group))
.unwrap();
let oa_receive = p
.add_element(Kind::Activity, Layer::Oa, "Perform repair", Some(oa_group))
.unwrap();
let sa_group = p
.add_element(Kind::Function, Layer::Sa, "System response", None)
.unwrap();
let sa_send = p
.add_element(Kind::Function, Layer::Sa, "Dispatch repair", Some(sa_group))
.unwrap();
let sa_receive = p
.add_element(Kind::Function, Layer::Sa, "Execute repair", Some(sa_group))
.unwrap();
let oa_edge = p.relate(
RelationKind::Interaction,
"Repair request",
oa_send,
oa_receive,
);
let sa_edge = p.relate(
RelationKind::FunctionalExchange,
"Repair dispatch",
sa_send,
sa_receive,
);
let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = p.relations[&sa_edge].body
else {
unreachable!()
};
let trace = Id::new_v4();
let refinement = Trace {
id: trace,
alias: p.fresh_alias("dispatch_refinement", "trace", trace),
name: "Dispatch refines repair request".into(),
description: "Keep this cross-layer trace and its authored route.".into(),
oa: oa_send,
sa: sa_send,
kind: TraceKind::Refinement,
baseline: Some(TraceBaseline {
oa: p.properties(oa_send).unwrap(),
sa: p.properties(sa_send).unwrap(),
semantic: None,
}),
};
apply(&mut p, Command::CreateTrace(refinement));
let d = p.ensure_diagram(layer);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
let oa_group_node = p.place(d, oa_group, xy(0., 0.));
let oa_send_node = p.place(d, oa_send, xy(40., 120.));
let oa_receive_node = p.place(d, oa_receive, xy(360., 120.));
let sa_group_node = p.place(d, sa_group, xy(0., 500.));
let sa_send_node = p.place(d, sa_send, xy(40., 620.));
let sa_receive_node = p.place(d, sa_receive, xy(360., 620.));
for (node, port, side, offset) in [
(sa_send_node, source_port, PortAnchorSide::Bottom, 0.25),
(sa_receive_node, target_port, PortAnchorSide::Top, 0.75),
] {
apply(
&mut p,
Command::SetPortAnchor {
diagram: d,
node,
anchor: PortAnchor { port, side, offset },
},
);
}
for (edge, y, port_backed) in [
(oa_edge, 260., false),
(sa_edge, 860., true),
(trace, 420., false),
] {
apply(
&mut p,
Command::SetEdgeRoute {
diagram: d,
route: EdgeRoute {
edge,
source_attachment: (!port_backed).then_some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.3,
}),
target_attachment: (!port_backed).then_some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.7,
}),
waypoints: vec![xy(680., y), xy(740., y)],
label: Some(EdgeLabel {
fraction: 0.4,
offset: xy(13., -27.),
}),
},
},
);
}
for node in [oa_group_node, sa_group_node] {
let mut placement = p.diagrams[&d].nodes[&node].clone();
placement.collapsed = collapsed;
placement.size = Size {
width: 310.,
height: 170.,
};
apply(
&mut p,
Command::EditNode {
diagram: d,
original: node,
node: placement,
},
);
}
validate_candidate(&p).unwrap();
Populated {
project: p,
diagram: d,
groups: [oa_group_node, sa_group_node],
children: [
[oa_send_node, oa_receive_node],
[sa_send_node, sa_receive_node],
],
edges: [oa_edge, sa_edge],
ports: [source_port, target_port],
trace,
}
}
fn assert_round_trips(p: &Project) {
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
assert_eq!(
persistence::decode(&serde_json::to_vec(p).unwrap()).unwrap(),
*p
);
assert_eq!(
compile_bundle(&format_bundle(p), ProjectLimits::default())
.unwrap()
.decoded
.project,
*p
);
}
fn assert_layer_only(p: &Project, d: Id, view: &ProjectedView) {
let layer = p.diagrams[&d].layer;
for node in &view.nodes {
assert_eq!(p.elements[&node.element].layer(), layer);
if let Some(parent) = node.visual_parent {
let parent = view.nodes.iter().find(|n| n.id == parent).unwrap();
assert_eq!(p.elements[&parent.element].layer(), layer);
}
}
for port in &view.ports {
assert_eq!(p.elements[&port.element].layer(), layer);
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
assert_eq!(p.elements[&owner.element].layer(), layer);
}
for edge in &view.edges {
assert!(
!edge.trace,
"cross-layer trace leaked into ordinary projection"
);
assert_eq!(p.layer_of(edge.id), Some(layer));
for endpoint in [edge.source, edge.target] {
let owner = view.nodes.iter().find(|n| n.id == endpoint).unwrap();
assert_eq!(p.elements[&owner.element].layer(), layer);
}
for port in [edge.source_port, edge.target_port].into_iter().flatten() {
assert_eq!(p.elements[&port].layer(), layer);
}
}
}
#[test]
fn populated_mixed_projection_switch_conserves_authorship_and_restores_geometry() {
for layer in [Layer::Oa, Layer::Sa] {
for collapsed in [false, true] {
let f = fixture(layer, collapsed);
let baseline = &f.project;
let d = f.diagram;
let before_view = project_view(baseline, d);
let before_routes = route_diagram(baseline, &baseline.diagrams[&d], &before_view);
// Guard against a vacuous fixture: Traceability deliberately flattens parents.
assert_eq!(before_view.nodes.len(), 6);
assert!(before_view.nodes.iter().all(|n| n.visual_parent.is_none()));
for id in [f.edges[0], f.edges[1], f.trace] {
assert!(before_view.edges.iter().any(|e| e.id == id));
let route = baseline.diagrams[&d]
.routes
.iter()
.find(|r| r.edge == id)
.unwrap();
assert_eq!(route.waypoints.len(), 2);
assert!(route.label.is_some());
}
for port in f.ports {
assert!(before_view.ports.iter().any(|p| p.element == port));
}
assert_round_trips(baseline);
for projection in [
ProjectionKind::Overview,
ProjectionKind::Decomposition,
ProjectionKind::Allocation,
ProjectionKind::FunctionalDataflow,
ProjectionKind::Capabilities,
] {
let mut expected = baseline.clone();
expected.diagrams.get_mut(&d).unwrap().projection = projection;
let mut session = Session::new(baseline.clone());
session
.execute(Command::EditDiagram(d, expected.diagrams[&d].clone()))
.unwrap();
assert_eq!(session.project(), &expected);
assert_round_trips(session.project());
let view = project_view(session.project(), d);
assert_eq!(
view,
project_view_with_index(&expected, d, &ViewIndex::new(&expected))
);
assert_layer_only(session.project(), d, &view);
if projection == ProjectionKind::Overview {
let own = if layer == Layer::Oa { 0 } else { 1 };
assert!(view.nodes.iter().any(|n| n.id == f.groups[own]));
for child in f.children[own] {
if collapsed {
assert!(!view.nodes.iter().any(|n| n.id == child));
} else {
assert_eq!(
view.nodes
.iter()
.find(|n| n.id == child)
.unwrap()
.visual_parent,
Some(f.groups[own])
);
}
}
if !collapsed {
assert!(view.edges.iter().any(|e| e.id == f.edges[own]));
if layer == Layer::Sa {
assert_eq!(view.ports.len(), 2);
}
}
}
session.undo();
assert_eq!(session.project(), baseline);
assert!(
!session.can_undo(),
"projection change must be one model history step"
);
assert_round_trips(session.project());
session.redo();
assert_eq!(session.project(), &expected);
session
.execute(Command::EditDiagram(d, baseline.diagrams[&d].clone()))
.unwrap();
assert_eq!(session.project(), baseline);
let restored = project_view(session.project(), d);
assert_eq!(restored, before_view);
assert_eq!(
route_diagram(
session.project(),
&session.project().diagrams[&d],
&restored
),
before_routes
);
assert_round_trips(session.project());
}
}
}
}