Preserve nested architecture during authoring and distinguish allocation summaries

This commit is contained in:
2026-09-05 17:58:46 +03:00
parent c20a2073d7
commit c7101b970a
13 changed files with 2719 additions and 1653 deletions
+111 -17
View File
@@ -873,7 +873,17 @@ impl App {
.relations
.get(&selected)
.map(|r| vec![r.source(), r.target()])
.unwrap_or_else(|| vec![selected]);
.unwrap_or_else(|| vec![selected])
.into_iter()
.map(|id| {
self.project()
.elements
.get(&id)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(id)
})
.collect::<Vec<_>>();
let nodes = view
.nodes
.values()
@@ -1076,11 +1086,15 @@ impl App {
.cloned()
{
n.collapsed = !n.collapsed;
self.execute(Command::EditNode {
let selected = self.selected;
if self.execute(Command::EditNode {
diagram,
original: n.id,
node: n,
});
}) {
self.selected = selected;
self.refresh_draft();
}
}
}
Message::MoveNode(node, position) => {
@@ -1421,28 +1435,35 @@ impl App {
let id = e.id;
let mut commands = vec![Command::CreateElement(e)];
let mut diagram_target = self.active_diagram;
let preferred = if self.create_kind.is_work() || self.create_kind.is_performer() {
Some(ProjectionKind::Allocation)
} else if ProjectionKind::Capabilities.includes(self.create_kind) {
Some(ProjectionKind::Capabilities)
} else {
None
};
if self.page == Page::Diagram
&& self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
.is_some_and(|d| !d.projection.includes(self.create_kind))
&& ProjectionKind::Capabilities.includes(self.create_kind)
&& let Some(preferred) = preferred
{
diagram_target = self
.project()
.diagrams
.values()
.find(|d| d.layer == self.layer && d.projection == ProjectionKind::Capabilities)
.find(|d| d.layer == self.layer && d.projection == preferred)
.map(|d| d.id);
if diagram_target.is_none() {
let view = Id::new_v4();
commands.push(Command::CreateDiagram(Diagram {
id: view,
alias: self.project().fresh_alias("capabilities", "view", view),
name: format!("{} missions and capabilities", self.layer),
alias: self.project().fresh_alias("engineering_view", "view", view),
name: format!("{} {preferred}", self.layer),
description: String::new(),
layer: self.layer,
projection: ProjectionKind::Capabilities,
projection: preferred,
nodes: Default::default(),
}));
diagram_target = Some(view);
@@ -1486,25 +1507,98 @@ impl App {
_ => unreachable!(),
},
);
let count = p.diagrams[&diagram].nodes.len();
let node = p.place(
diagram,
id,
Position {
x: 60. + (count % 3) as f32 * 280.,
y: 60. + (count / 3) as f32 * 150.,
},
);
let owner = if allocated {
Some(self.selected)
} else {
parent
};
// Insertion measures an expanded owner path and commits that reveal
// with the new child, so folded contents cannot overlap the new row.
if let Some(owner) = owner {
let mut expanded = p.diagrams[&diagram].clone();
for node in expanded.nodes.values_mut() {
node.collapsed = false;
}
let expanded_view = oa_core::projection::project_diagram(&p, &expanded);
let mut current = expanded_view
.nodes
.iter()
.find(|n| n.element == owner)
.map(|n| n.id);
while let Some(id) = current {
let node = p
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&id)
.unwrap();
if node.collapsed {
node.collapsed = false;
commands.push(Command::EditNode {
diagram,
original: id,
node: node.clone(),
});
}
current = expanded_view
.nodes
.iter()
.find(|n| n.id == id)
.and_then(|n| n.visual_parent);
}
}
// Reserve the expanded size of folded child containers so inserting
// a sibling does not overlap their contents when they are unfolded.
let mut expanded = p.diagrams[&diagram].clone();
for node in expanded.nodes.values_mut() {
node.collapsed = false;
}
let view = oa_core::projection::project_diagram(&p, &expanded);
let position =
if let Some(container) = view.nodes.iter().find(|n| Some(n.element) == owner) {
let y = view
.nodes
.iter()
.filter(|n| n.visual_parent == Some(container.id))
.map(|n| n.position.y + n.size.height + 16.)
.fold(container.position.y + 64., f32::max);
Position {
x: container.position.x + 24.,
y,
}
} else {
Position {
x: view
.nodes
.iter()
.map(|n| n.position.x + n.size.width + 240.)
.fold(60., f32::max),
y: 60.,
}
};
let node = p.place(diagram, id, position);
commands.push(Command::CreateNode {
diagram,
node: p.diagrams[&diagram].nodes[&node].clone(),
});
}
if self.page == Page::Diagram
&& let Some(diagram) = diagram_target
&& let Ok(candidate) = execute_batch(self.project(), &commands)
{
commands.extend(crate::diagram::insertion_adjustments(
&candidate, diagram, id,
));
}
if self.execute(Command::Batch(commands)) {
self.active_diagram = diagram_target;
self.selected = id;
self.inspector_tab = crate::navigation::InspectorTab::Properties;
self.refresh_draft();
if self.page == Page::Diagram {
self.fit();
}
self.status = "Element created · enter its engineering name and properties".into();
}
}
+310 -54
View File
@@ -25,6 +25,101 @@ pub struct State {
preview: Option<Position>,
pan_start: Option<Point>,
}
/// Make room along the inserted node's containment path, moving only colliding
/// later siblings and their descendants. Existing semantic ownership is unchanged.
pub fn insertion_adjustments(p: &Project, diagram_id: Id, element: Id) -> Vec<Command> {
let mut diagram = p.diagrams[&diagram_id].clone();
// Reflow folded subtrees at their expanded size, without changing the
// user's fold state. Their hidden placements move with their container.
for node in diagram.nodes.values_mut() {
node.collapsed = false;
}
let view = oa_core::projection::project_diagram(p, &diagram);
let Some(inserted) = view.nodes.iter().find(|n| n.element == element) else {
return vec![];
};
let mut groups = Vec::new();
let mut affected = inserted.id;
let mut parent = inserted.visual_parent;
loop {
groups.push((parent, affected));
let Some(id) = parent else {
break;
};
affected = id;
parent = view
.nodes
.iter()
.find(|n| n.id == id)
.and_then(|n| n.visual_parent);
}
for (group, affected) in groups {
let view = oa_core::projection::project_diagram(p, &diagram);
let mut siblings: Vec<_> = view
.nodes
.iter()
.filter(|n| n.visual_parent == group)
.collect();
siblings.sort_by(|a, b| {
a.position
.y
.total_cmp(&b.position.y)
.then(a.position.x.total_cmp(&b.position.x))
.then(a.id.cmp(&b.id))
});
let ids: Vec<_> = siblings.iter().map(|n| n.id).collect();
let mut settled: Vec<(Rectangle, bool)> = Vec::new();
for id in ids {
let current = oa_core::projection::project_diagram(p, &diagram);
let node = current.nodes.iter().find(|n| n.id == id).unwrap();
let mut rect = Rectangle::new(
Point::new(node.position.x, node.position.y),
Size::new(node.size.width, node.size.height),
);
let original_y = rect.y;
let displaced = id == affected
|| settled
.iter()
.any(|(other, moved)| *moved && rect.intersects(other));
if displaced {
loop {
let next_y = settled
.iter()
.filter(|(other, _)| rect.intersects(other))
.map(|(other, _)| other.y + other.height + 24.)
.fold(rect.y, f32::max);
if next_y == rect.y {
break;
}
rect.y = next_y;
}
}
let dy = rect.y - original_y;
if dy > 0. {
let mut moved = oa_core::projection::descendant_nodes(p, diagram_id, id);
moved.push(id);
for moved in moved {
diagram.nodes.get_mut(&moved).unwrap().position.y += dy;
}
}
settled.push((rect, displaced));
}
}
diagram
.nodes
.into_values()
.map(|mut node| {
node.collapsed = p.diagrams[&diagram_id].nodes[&node.id].collapsed;
node
})
.filter(|n| p.diagrams[&diagram_id].nodes[&n.id] != *n)
.map(|node| Command::EditNode {
diagram: diagram_id,
original: node.id,
node,
})
.collect()
}
impl DiagramCanvas<'_> {
fn world(&self, p: Point) -> Point {
Point::new(
@@ -68,30 +163,21 @@ impl DiagramCanvas<'_> {
})
}
fn preview_view(&self, state: &State) -> ProjectedView {
let mut view = project_view(self.project, self.diagram);
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
let dx = preview.x - original.x;
let dy = preview.y - original.y;
let mut moved = BTreeSet::from([id]);
for n in &view.nodes {
if n.visual_parent.is_some_and(|p| moved.contains(&p)) {
moved.insert(n.id);
}
}
for n in &mut view.nodes {
if moved.contains(&n.id) {
let mut diagram = self.project.diagrams[&self.diagram].clone();
let mut moved = oa_core::projection::descendant_nodes(self.project, self.diagram, id);
moved.push(id);
for id in moved {
if let Some(n) = diagram.nodes.get_mut(&id) {
n.position.x += dx;
n.position.y += dy;
}
}
for port in &mut view.ports {
if moved.contains(&port.owner) {
port.position.x += dx;
port.position.y += dy;
}
}
return oa_core::projection::project_diagram(self.project, &diagram);
}
view
project_view(self.project, self.diagram)
}
}
impl canvas::Program<Message> for DiagramCanvas<'_> {
@@ -250,20 +336,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
} else {
Color::from_rgb8(144, 158, 173)
};
let capability_label =
self.project
.relations
.get(&edge.id)
.and_then(|r| match r.kind() {
RelationKind::CapabilityInclude => Some("«include»"),
RelationKind::CapabilityExtend => Some("«extend»"),
RelationKind::CapabilityGeneralization => Some("specializes"),
RelationKind::MissionCapability => Some("exploits"),
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
Some("participates")
}
_ => None,
});
let capability_label = relationship_stereotype(self.project, edge.id);
let mut stroke =
Stroke::default()
.with_color(color)
@@ -418,23 +491,23 @@ fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, conta
a: 1.,
};
f.fill(&path, if container { tint } else { Color::WHITE });
f.stroke(
&path,
Stroke::default()
.with_color(if e.id == selected { color } else { style::LINE })
.with_width(if e.id == selected { 2.6 } else { 1.2 }),
);
let mut stroke = Stroke::default()
.with_color(if e.id == selected { color } else { style::LINE })
.with_width(if e.id == selected { 2.6 } else { 1.2 });
if n.allocation_summary {
stroke.line_dash = canvas::LineDash {
segments: &[6., 4.],
offset: 0,
};
}
f.stroke(&path, stroke);
f.fill_rectangle(
Point::new(origin.x + 14., origin.y + 15.),
Size::new(4., 12.),
color,
);
f.fill_text(Text {
content: format!(
"{}{}",
e.kind().label().to_uppercase(),
if n.collapsed { " +" } else { "" }
),
content: node_header(e.kind(), n),
position: Point::new(origin.x + 25., origin.y + 14.),
color,
size: 11.into(),
@@ -447,19 +520,32 @@ fn paint_node(f: &mut Frame, p: &Project, n: &ProjectedNode, selected: Id, conta
position: Point::new(origin.x + 14., origin.y + 35.),
color: style::INK,
size: 16.into(),
font: iced::Font {
style: if n.allocation_summary {
iced::font::Style::Italic
} else {
iced::font::Style::Normal
},
..iced::Font::DEFAULT
},
max_width: f32::INFINITY,
..Default::default()
});
if !container && size.height >= 86. {
f.fill_text(Text {
content: format!("@{}", short(&e.alias, max_chars + 6)),
position: Point::new(origin.x + 14., origin.y + 66.),
color: style::MUTED,
size: 10.5.into(),
max_width: f32::INFINITY,
..Default::default()
});
}
}
fn node_header(kind: Kind, node: &ProjectedNode) -> String {
let header = if node.allocation_summary {
format!(
"{} · SUMMARY",
if kind == Kind::Function {
"FUNCTION"
} else {
"ACTIVITY"
}
)
} else {
kind.label().to_uppercase()
};
format!("{header}{}", if node.collapsed { " +" } else { "" })
}
fn highlighted_exchanges(p: &Project, selected: Id) -> BTreeSet<Id> {
p.elements
@@ -489,6 +575,18 @@ fn edge_endpoints(view: &ProjectedView, edge: &ProjectedEdge) -> Option<(Point,
port_point(edge.target_port).unwrap_or(end),
))
}
fn relationship_stereotype(p: &Project, id: Id) -> Option<&'static str> {
match p.relations.get(&id)?.kind() {
RelationKind::CapabilityInclude => Some("«include»"),
RelationKind::CapabilityExtend => Some("«extend»"),
RelationKind::CapabilityGeneralization => Some("specializes"),
RelationKind::MissionCapability => Some("exploits"),
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
Some("participates")
}
_ => None,
}
}
fn endpoints(a: &ProjectedNode, b: &ProjectedNode) -> (Point, Point) {
let ax = a.position.x + a.size.width / 2.;
let ay = a.position.y + a.size.height / 2.;
@@ -594,8 +692,13 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
let color = hex(style::kind_color(e.kind()));
let x = n.position.x;
let y = n.position.y;
let (border, name_style) = if n.allocation_summary {
(" stroke-dasharray=\"6 4\"", " font-style=\"italic\"")
} else {
("", "")
};
format!(
"<g><rect x=\"{x}\" y=\"{y}\" width=\"{}\" height=\"{}\" rx=\"10\" fill=\"{}\" stroke=\"{color}\"/><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"11\" fill=\"{color}\">{}</text><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"16\" fill=\"#223044\">{}</text></g>",
"<g><rect x=\"{x}\" y=\"{y}\" width=\"{}\" height=\"{}\" rx=\"10\" fill=\"{}\" stroke=\"{color}\"{border}/><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"11\" fill=\"{color}\">{}</text><text x=\"{}\" y=\"{}\" font-family=\"sans-serif\" font-size=\"16\" fill=\"#223044\"{name_style}>{}</text></g>",
n.size.width,
n.size.height,
if containers.contains(&n.id) {
@@ -605,7 +708,7 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
},
x + 14.,
y + 25.,
escape(e.kind().label()),
escape(&node_header(e.kind(), n)),
x + 14.,
y + 51.,
escape(&short(
@@ -622,7 +725,27 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
for edge in &view.edges {
if let Some((a, b)) = edge_endpoints(&view, edge) {
let mid = (a.x + b.x) / 2.;
svg.push_str(&format!("<path d=\"M {} {} C {mid} {}, {mid} {}, {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\" marker-end=\"url(#arrow)\"><title>{}</title></path>",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id))));
let stereotype = relationship_stereotype(p, edge.id);
let dashed = if matches!(stereotype, Some("«include»" | "«extend»")) {
" stroke-dasharray=\"6 4\""
} else {
""
};
svg.push_str(&format!("<path d=\"M {} {} C {mid} {}, {mid} {}, {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed} marker-end=\"url(#arrow)\"><title>{}</title></path>",a.x,a.y,a.y,b.y,b.x,b.y,escape(p.named(edge.id))));
if stereotype.is_some()
|| matches!(
p.relations.get(&edge.id).map(|r| r.kind()),
Some(
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
)
)
{
let label = stereotype.unwrap_or_else(|| p.named(edge.id));
let center = curve_point(a, b, 0.5);
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"224\" height=\"24\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",center.x-112.,center.y-30.,center.x,center.y-8.,escape(&short(label,32))));
}
}
}
for n in &view.nodes {
@@ -632,6 +755,9 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
}
for port in &view.ports {
svg.push_str(&format!("<g><title>{}</title><rect x=\"{}\" y=\"{}\" width=\"14\" height=\"14\" fill=\"white\" stroke=\"#147d7b\"/><path d=\"M {} {} h 8 l -3 -3 m 3 3 l -3 3\" fill=\"none\" stroke=\"#147d7b\"/></g>",escape(p.named(port.element)),port.position.x-7.,port.position.y-7.,port.position.x-4.,port.position.y));
if port.direction == PortDirection::InOut {
svg.push_str(&format!("<path d=\"M {} {} l -3 -3 m 3 3 l -3 3\" transform=\"rotate(180 {} {})\" fill=\"none\" stroke=\"#147d7b\"/>",port.position.x+4.,port.position.y,port.position.x,port.position.y));
}
}
svg.push_str("</svg>");
svg
@@ -641,6 +767,136 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
mod tests {
use super::*;
#[test]
fn svg_distinguishes_inferred_work_allocation_from_direct_allocation() {
for (layer, performer_kind, work_kind, header) in [
(
Layer::Oa,
Kind::Entity,
Kind::Activity,
"ACTIVITY · SUMMARY",
),
(
Layer::Sa,
Kind::System,
Kind::Function,
"FUNCTION · SUMMARY",
),
] {
let mut p = Project::empty();
let owner = p
.add_element(performer_kind, layer, "Performer", None)
.unwrap();
let parent = p
.add_element(work_kind, layer, "Grouped work", None)
.unwrap();
let child = p
.add_element(work_kind, layer, "Allocated leaf", Some(parent))
.unwrap();
p.relate(RelationKind::Allocation, "Performs", owner, child);
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
for id in [owner, parent, child] {
p.place(diagram, id, Position { x: 60., y: 60. });
}
let p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
let svg = export_svg(&p, diagram);
let group = svg
.split("<g>")
.find(|g| g.contains(">Grouped work</text>"))
.unwrap();
assert!(group.contains(&format!(">{header}</text>")));
assert!(group.contains("stroke-dasharray=\"6 4\""));
assert!(group.contains("font-style=\"italic\""));
let leaf = svg
.split("<g>")
.find(|g| g.contains(">Allocated leaf</text>"))
.unwrap();
assert!(!leaf.contains("SUMMARY"));
assert!(!leaf.contains("stroke-dasharray"));
assert!(!leaf.contains("font-style=\"italic\""));
assert!(
!p.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation && r.target() == parent)
);
}
}
#[test]
fn dragging_a_child_previews_the_same_enclosure_and_ports_as_commit() {
let p = Project::demo();
let diagram = p
.diagrams
.values()
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
.unwrap()
.id;
let view = project_view(&p, diagram);
let child = view
.nodes
.iter()
.find(|n| n.visual_parent.is_some())
.unwrap()
.id;
let original = p.diagrams[&diagram].nodes[&child].position;
let target = Position {
x: original.x - 400.,
y: original.y - 200.,
};
let canvas = DiagramCanvas {
project: &p,
diagram,
selected: p.id,
zoom: 1.,
pan: Vector::ZERO,
};
let state = State {
drag: Some((child, Point::ORIGIN, original)),
preview: Some(target),
..State::default()
};
let preview = canvas.preview_view(&state);
let committed = execute_batch(
&p,
&[Command::MoveNode {
diagram,
node: child,
position: target,
with_descendants: true,
}],
)
.unwrap();
assert_eq!(preview, project_view(&committed, diagram));
}
#[test]
fn svg_preserves_visible_exchange_labels_and_capability_stereotypes() {
let mut p = Project::demo();
let diagram = p
.diagrams
.values()
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
.unwrap()
.id;
let view = project_view(&p, diagram);
let svg = export_svg(&p, diagram);
for edge in &view.edges {
assert!(svg.contains(&format!(">{}</text>", escape(&short(p.named(edge.id), 32)))));
}
let a = p
.add_element(Kind::Capability, Layer::Oa, "Primary", None)
.unwrap();
let b = p
.add_element(Kind::Capability, Layer::Oa, "Included", None)
.unwrap();
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Capabilities;
p.place(d, a, Position { x: 0., y: 0. });
p.place(d, b, Position { x: 400., y: 0. });
p.relate(RelationKind::CapabilityInclude, "Uses", a, b);
let svg = export_svg(&p, d);
assert!(svg.contains("«include»</text>"));
assert!(svg.contains("stroke-dasharray=\"6 4\""));
}
#[test]
fn implicit_port_click_selects_semantics_and_drag_preview_svg_share_anchors() {
let p = Project::demo();
let diagram = p
+7 -3
View File
@@ -173,7 +173,7 @@ fn article(topic: Topic) -> Article {
"Create an operational context",
&[
"Choose New in the top bar. Use Project properties to name the model and describe its purpose. Apply changes commits the property draft.",
"On Architecture, choose Create architecture view. The initial Allocation projection shows work inside its performer.",
"On Architecture, choose Create architecture view. Architecture · allocated work shows work inside its performer.",
"Choose Entity under Create element, then + At root. Name it Rescue coordinator and Apply changes. Use Actor for an external participant such as a Vessel in distress.",
"Select Rescue coordinator, choose Activity, then + Allocated. Name the new activity Assess incident. It appears inside its performer; an Allocation relationship records that responsibility.",
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
@@ -239,7 +239,7 @@ fn article(topic: Topic) -> Article {
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
"Create Activity in OA or Function in SA. Select existing work and use + In selected to decompose it into smaller work units.",
"To create work for a participant, select the participant, choose Activity or Function, then + Allocated. To allocate existing work, use + Relationship with Allocation from the participant to the leaf work.",
"Open the Allocation projection to review responsibilities. Open Decomposition to review the activity/function hierarchy independently.",
"Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.",
],
),
explain(
@@ -274,7 +274,7 @@ 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 and projection.",
"Choose Decomposition to nest semantic children, or Allocation to nest work under its performer. Overview keeps a broader view; OA → SA displays counterpart traces.",
"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.",
"Drag nodes to arrange the current view, scroll to zoom and use Fit to frame its contents. Arrange changes diagram geometry. Export SVG produces a shareable view.",
"Select a diagram node to access Placement, Fold / unfold and Hide. Hide removes its view placement; Delete from model removes model records and requires review of the affected references.",
@@ -284,6 +284,10 @@ 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.",
),
explain(
"Read allocation summaries and ports",
"A dashed parent activity/function with an italic name and SUMMARY heading groups leaves assigned to the same performer. It has no direct allocation of its own; its inspector explains the leaf performer. Small ports belong to the boundary of their visible owner and are selectable model elements. Folding a container hides its inner work and ports; summary exchange lines do not invent new ports on the container.",
),
explain(
"What nesting does not imply",
"Containment in an allocation projection expresses a performer relationship. It is not a physical deployment statement. Physical deployment is a different architectural concern; do not infer hardware installation from an OA/SA box layout.",
+296
View File
@@ -936,3 +936,299 @@ fn behavior_and_data_authoring_do_not_place_nodes_in_unrelated_architecture_view
}
}
}
#[test]
fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner() {
let mut p = Project::demo();
let diagram = p
.diagrams
.values()
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
.unwrap()
.id;
let view = oa_core::projection::project_view(&p, diagram);
let function = view
.nodes
.iter()
.find(|n| p.elements[&n.element].kind() == Kind::Function && n.visual_parent.is_some())
.unwrap();
let ancestor = function.visual_parent.unwrap();
let port = p
.children(function.element)
.find(|e| e.kind() == Kind::Port)
.unwrap()
.id;
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&ancestor)
.unwrap()
.collapsed = true;
assert!(crate::navigation::diagrams_containing(&p, port).contains(&diagram));
let mut app = App::with_project(p);
send(&mut app, Message::RevealInDiagram(diagram, port));
assert_eq!(app.selected, port);
assert_eq!(app.active_diagram, Some(diagram));
assert!(!app.project().diagrams[&diagram].nodes[&ancestor].collapsed);
assert!(
oa_core::projection::project_view(app.project(), diagram)
.ports
.iter()
.any(|p| p.element == port)
);
}
#[test]
fn creation_from_capabilities_routes_work_to_architecture_and_folded_allocation_inserts_below_existing_work()
{
let p = Project::demo();
let system = p
.elements
.values()
.find(|e| e.kind() == Kind::System)
.unwrap()
.id;
let capabilities = p
.diagrams
.values()
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Capabilities)
.unwrap()
.id;
let mut app = App::with_project(p);
send(&mut app, Message::Layer(Layer::Sa));
send(
&mut app,
Message::Diagram(crate::app::Choice {
id: capabilities,
label: String::new(),
}),
);
send(&mut app, Message::CreateKind(Kind::Function));
send(&mut app, Message::Add(false));
let diagram = app.active_diagram.unwrap();
assert_eq!(
app.project().diagrams[&diagram].projection,
ProjectionKind::Allocation
);
assert!(
app.project().diagrams[&diagram]
.nodes
.values()
.any(|n| n.element == app.selected)
);
send(&mut app, Message::Select(system));
let owner = app.selected_node.unwrap();
send(&mut app, Message::ToggleCollapse);
assert!(app.project().diagrams[&diagram].nodes[&owner].collapsed);
assert_eq!(
app.selected, system,
"Folding must retain semantic selection"
);
let before = app.project().clone();
send(&mut app, Message::Add(true));
let created = app.selected;
let view = oa_core::projection::project_view(app.project(), diagram);
let child = view
.nodes
.iter()
.find(|n| n.element == created)
.unwrap_or_else(|| panic!("Created work is not visible: {}", app.status));
assert_eq!(child.visual_parent, Some(owner));
assert!(!app.project().diagrams[&diagram].nodes[&owner].collapsed);
assert!(
view.nodes
.iter()
.filter(|n| n.visual_parent == Some(owner) && n.id != child.id)
.all(|n| n.position.y + n.size.height <= child.position.y)
);
let parent = view.nodes.iter().find(|n| n.id == owner).unwrap();
assert!(
view.nodes
.iter()
.filter(|n| n.visual_parent.is_none() && n.id != owner)
.all(|n| n.position.x + n.size.width <= parent.position.x
|| parent.position.x + parent.size.width <= n.position.x
|| n.position.y + n.size.height <= parent.position.y
|| parent.position.y + parent.size.height <= n.position.y)
);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before);
}
#[test]
fn nested_allocation_insertion_reflows_sibling_subtrees_and_preserves_fold_state() {
use oa_core::projection::{project_diagram, project_view};
for folded in [false, true] {
let mut p = Project::empty();
let root = p
.add_element(Kind::Entity, Layer::Oa, "Root", None)
.unwrap();
let a = p
.add_element(Kind::Entity, Layer::Oa, "A", Some(root))
.unwrap();
let b = p
.add_element(Kind::Entity, Layer::Oa, "B", Some(root))
.unwrap();
let other = p
.add_element(Kind::Entity, Layer::Oa, "Other root", None)
.unwrap();
let a_work = p
.add_element(Kind::Activity, Layer::Oa, "A work", None)
.unwrap();
let b_work = p
.add_element(Kind::Activity, Layer::Oa, "B work", None)
.unwrap();
let b_port = p
.add_element(Kind::Port, Layer::Oa, "B boundary", Some(b))
.unwrap();
p.relate(RelationKind::Allocation, "A performs", a, a_work);
p.relate(RelationKind::Allocation, "B performs", b, b_work);
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
for id in [root, a, b, other, a_work, b_work] {
p.place(diagram, id, Position { x: 60., y: 60. });
}
let mut p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
let view = project_view(&p, diagram);
let root_node = view.nodes.iter().find(|n| n.element == root).unwrap();
let a_node = view.nodes.iter().find(|n| n.element == a).unwrap();
let b_node = view.nodes.iter().find(|n| n.element == b).unwrap();
// Layout's UUID order is intentionally irrelevant to this scenario:
// explicitly put A before B, as a user can arrange these participants.
p = execute_batch(
&p,
&[
Command::MoveNode {
diagram,
node: a_node.id,
position: Position {
x: root_node.position.x + 24.,
y: root_node.position.y + 64.,
},
with_descendants: true,
},
Command::MoveNode {
diagram,
node: b_node.id,
position: Position {
x: root_node.position.x + 24.,
y: root_node.position.y + 64. + a_node.size.height + 16.,
},
with_descendants: true,
},
],
)
.unwrap();
let view = project_view(&p, diagram);
let root_node = view.nodes.iter().find(|n| n.element == root).unwrap();
let other_node = view.nodes.iter().find(|n| n.element == other).unwrap().id;
let b_node = view.nodes.iter().find(|n| n.element == b).unwrap().id;
let b_work_node = view.nodes.iter().find(|n| n.element == b_work).unwrap().id;
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&other_node)
.unwrap()
.position = Position {
x: root_node.position.x,
y: root_node.position.y + root_node.size.height + 24.,
};
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&b_node)
.unwrap()
.collapsed = folded;
// Deliberately overlapping unrelated roots must not be rearranged.
let mut unrelated = Vec::new();
for name in ["Unrelated one", "Unrelated two"] {
let id = p.add_element(Kind::Entity, Layer::Oa, name, None).unwrap();
unrelated.push(p.place(diagram, id, Position { x: 2000., y: 1000. }));
}
let before = p.clone();
let mut expanded = p.diagrams[&diagram].clone();
expanded.nodes.get_mut(&b_node).unwrap().collapsed = false;
let before_view = project_diagram(&p, &expanded);
let before_port = before_view
.ports
.iter()
.find(|p| p.element == b_port)
.unwrap()
.position;
let mut app = App::with_project(p);
send(&mut app, Message::Select(a));
send(&mut app, Message::CreateKind(Kind::Activity));
send(&mut app, Message::Add(true));
let created = app.selected;
assert_eq!(
app.project().elements[&created].kind(),
Kind::Activity,
"{}",
app.status
);
let after = &app.project().diagrams[&diagram];
assert_eq!(after.nodes[&b_node].collapsed, folded);
let dy =
after.nodes[&b_node].position.y - before.diagrams[&diagram].nodes[&b_node].position.y;
assert!(dy > 0., "B must move below the enlarged A");
assert_eq!(
after.nodes[&b_work_node].position.y,
before.diagrams[&diagram].nodes[&b_work_node].position.y + dy
);
assert!(
after.nodes[&other_node].position.y
> before.diagrams[&diagram].nodes[&other_node].position.y
);
for id in unrelated {
assert_eq!(after.nodes[&id], before.diagrams[&diagram].nodes[&id]);
}
let mut expanded = after.clone();
expanded.nodes.get_mut(&b_node).unwrap().collapsed = false;
let view = project_diagram(app.project(), &expanded);
let after_port = view
.ports
.iter()
.find(|p| p.element == b_port)
.unwrap()
.position;
assert_eq!(
after_port,
Position {
x: before_port.x,
y: before_port.y + dy
}
);
for (left, right) in [(a, b), (root, other), (a_work, created)] {
let left = view.nodes.iter().find(|n| n.element == left).unwrap();
let right = view.nodes.iter().find(|n| n.element == right).unwrap();
assert!(left.position.y + left.size.height <= right.position.y);
}
assert!(crate::diagram::insertion_adjustments(app.project(), diagram, created).is_empty());
send(&mut app, Message::Undo);
assert_eq!(
app.project(),
&before,
"Creation and all reflow must undo together"
);
if folded {
send(&mut app, Message::Select(root));
send(&mut app, Message::CreateKind(Kind::Entity));
send(&mut app, Message::Add(true));
let mut expanded = app.project().diagrams[&diagram].clone();
expanded.nodes.get_mut(&b_node).unwrap().collapsed = false;
let view = project_diagram(app.project(), &expanded);
let b = view.nodes.iter().find(|n| n.id == b_node).unwrap();
let created = view
.nodes
.iter()
.find(|n| n.element == app.selected)
.unwrap();
assert!(b.position.y + b.size.height <= created.position.y);
assert!(app.project().diagrams[&diagram].nodes[&b_node].collapsed);
}
}
}
+17
View File
@@ -599,6 +599,23 @@ fn inspector(app: &App) -> View<'_, Message> {
.width(Fill),
);
}
if !p
.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation && r.target() == e.id)
&& let Some(performer) = oa_core::validation::allocation_summary(p, e.id)
{
body = body.push(label(
"Allocation summary: all leaf activities or functions belong to the same performer. The parent has no direct allocation.",
));
body = body.push(
action(
&format!("Leaf performer: {}", short(p.named(performer), 24)),
Message::Select(performer),
)
.width(Fill),
);
}
if e.kind() == Kind::Port {
body = body.push(label(
"Shown automatically on the boundary of its visible owner.",