From e7a81637dd9eb1860e7c076048ad9fd2d1339e69 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 00:36:22 +0300 Subject: [PATCH] Add persisted state-machine diagram contexts and shared geometry --- crates/oa-core/src/language.rs | 1 + crates/oa-core/src/model.rs | 13 + crates/oa-core/src/projection.rs | 73 ++- crates/oa-core/src/projection/state.rs | 139 +++++ crates/oa-core/src/references.rs | 11 + crates/oa-core/src/routing.rs | 96 +++- crates/oa-core/src/session.rs | 41 +- crates/oa-core/src/validation.rs | 40 ++ crates/oa-core/tests/state_diagrams.rs | 744 +++++++++++++++++++++++++ 9 files changed, 1143 insertions(+), 15 deletions(-) create mode 100644 crates/oa-core/src/projection/state.rs create mode 100644 crates/oa-core/tests/state_diagrams.rs diff --git a/crates/oa-core/src/language.rs b/crates/oa-core/src/language.rs index 47a0035d..cd041ab8 100644 --- a/crates/oa-core/src/language.rs +++ b/crates/oa-core/src/language.rs @@ -357,6 +357,7 @@ fn reference_field(key: &str) -> bool { | "diagram" | "edge" | "port" + | "machine" ) } fn enum_field(key: &str) -> bool { diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs index 456050c5..b7d3d3c7 100644 --- a/crates/oa-core/src/model.rs +++ b/crates/oa-core/src/model.rs @@ -1190,6 +1190,7 @@ impl RelationBody { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub enum ProjectionKind { Overview, Decomposition, @@ -1197,10 +1198,21 @@ pub enum ProjectionKind { Traceability, FunctionalDataflow, Capabilities, + StateMachine { machine: Id }, } impl ProjectionKind { + pub fn state_machine(self) -> Option { + match self { + Self::StateMachine { machine } => Some(machine), + _ => None, + } + } + pub fn includes(self, kind: Kind) -> bool { match self { + Self::StateMachine { .. } => { + matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state() + } Self::Allocation => kind.is_performer() || kind.is_work(), Self::FunctionalDataflow => kind.is_work(), Self::Capabilities => { @@ -1221,6 +1233,7 @@ impl ProjectionKind { impl std::fmt::Display for ProjectionKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { + Self::StateMachine { .. } => "State machine", Self::Allocation => "Architecture ยท allocated work", Self::FunctionalDataflow => "Functional dataflow", Self::Capabilities => "Missions & capabilities", diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs index cefe2cef..4dc26a23 100644 --- a/crates/oa-core/src/projection.rs +++ b/crates/oa-core/src/projection.rs @@ -4,6 +4,10 @@ use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; mod port_layout; pub use port_layout::{port_anchor_offset, port_anchor_position}; +#[path = "projection/state.rs"] +mod state; +pub(crate) use state::node_size as state_node_size; +pub use state::{state_header_height, state_machine_contains, state_symbol_size}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ProjectedNode { @@ -79,9 +83,13 @@ pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap> { .nodes .values() .filter(|n| { - p.elements - .get(&n.element) - .is_some_and(|e| e.kind() != Kind::Port && d.projection.includes(e.kind())) + p.elements.get(&n.element).is_some_and(|e| { + e.kind() != Kind::Port + && d.projection.includes(e.kind()) + && d.projection + .state_machine() + .is_none_or(|machine| state_machine_contains(p, machine, e.id)) + }) }) .map(|n| (n.element, n.id)) .collect(); @@ -191,6 +199,11 @@ pub fn translated_routes( let (source, target) = edge_endpoint_placements(p, d, route.edge)?; let source_delta = deltas.get(&source)?; let target_delta = deltas.get(&target)?; + if d.projection.state_machine().is_some() + && !state::boundary_moves_with(p, d, route.edge, source_delta, deltas) + { + return None; + } if source_delta != target_delta || (source_delta.x == 0. && source_delta.y == 0.) || route.waypoints.is_empty() @@ -221,7 +234,12 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView { let Some(e) = p.elements.get(&n.element) else { continue; }; - if e.kind() == Kind::Port || !d.projection.includes(e.kind()) { + if e.kind() == Kind::Port + || !d.projection.includes(e.kind()) + || d.projection + .state_machine() + .is_some_and(|machine| !state_machine_contains(p, machine, e.id)) + { continue; } if d.projection != ProjectionKind::Traceability && e.layer() != d.layer { @@ -255,6 +273,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView { n.size }, ); + let size = if d.projection.state_machine().is_some() { + state::node_size(p, e, size, n.collapsed) + } else { + size + }; nodes.push(ProjectedNode { id: n.id, element: n.element, @@ -273,7 +296,10 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView { } } nodes.sort_by_key(|n| (n.depth, n.id)); - enclose(p, &mut nodes); + enclose_with_headers(p, &mut nodes, d.projection.state_machine().is_some()); + if d.projection.state_machine().is_some() { + state::snap_boundaries(p, &mut nodes); + } let visible: BTreeSet<_> = nodes.iter().map(|n| n.id).collect(); let placed: BTreeMap<_, _> = d.nodes.values().map(|n| (n.element, n.id)).collect(); let anchor = |element: Id| { @@ -313,6 +339,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView { continue; } let included = match d.projection { + ProjectionKind::StateMachine { machine } => matches!(&r.body, + RelationBody::StateTransition { transition_kind, .. } + if *transition_kind != TransitionKind::Internal + && state_machine_contains(p, machine, r.source()) + && state_machine_contains(p, machine, r.target())), ProjectionKind::Allocation => matches!( r.kind(), RelationKind::Interaction @@ -338,7 +369,12 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView { continue; } if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target())) - && source != target + && (source != target + || (d.projection.state_machine().is_some() + && r.source() == r.target() + && nodes + .iter() + .any(|n| n.id == source && n.element == r.source()))) { let (source_port, target_port) = match &r.body { RelationBody::FunctionalExchange { @@ -384,7 +420,13 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView { } } } - let ports = port_layout::layout(p, d, &mut nodes, &edges); + // Dedicated state views contain state symbols, not semantic component ports. + // Port layout would re-enclose already snapped boundary vertices. + let ports = if d.projection.state_machine().is_some() { + Vec::new() + } else { + port_layout::layout(p, d, &mut nodes, &edges) + }; ProjectedView { nodes, edges, @@ -393,13 +435,28 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView { } fn enclose(p: &Project, nodes: &mut [ProjectedNode]) { + enclose_with_headers(p, nodes, false); +} +fn enclose_with_headers(p: &Project, nodes: &mut [ProjectedNode], state_view: bool) { for index in (0..nodes.len()).rev() { let child = nodes[index].clone(); + if state_view + && p.elements + .get(&child.element) + .is_some_and(|e| matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint)) + { + continue; + } if let Some(parent) = child .visual_parent .and_then(|id| nodes.iter().position(|n| n.id == id)) { let footer = port_footer(p, nodes[parent].element); + let header = if state_view { + state_header_height(p, nodes[parent].element) + } else { + 64. + }; let p = &mut nodes[parent]; let right = (p.position.x + p.size.width).max(child.position.x + child.size.width + 24.); @@ -407,7 +464,7 @@ fn enclose(p: &Project, nodes: &mut [ProjectedNode]) { (p.position.y + p.size.height).max(child.position.y + child.size.height + footer); p.position.x = p.position.x.min(child.position.x - 24.); // Reserve two readable header lines above the first nested card. - p.position.y = p.position.y.min(child.position.y - 64.); + p.position.y = p.position.y.min(child.position.y - header); p.size.width = right - p.position.x; p.size.height = bottom - p.position.y; } diff --git a/crates/oa-core/src/projection/state.rs b/crates/oa-core/src/projection/state.rs new file mode 100644 index 00000000..cc9e55b7 --- /dev/null +++ b/crates/oa-core/src/projection/state.rs @@ -0,0 +1,139 @@ +//! State-specific presentation policies over the shared persisted placements. +use super::*; + +/// Membership of a dedicated state view. Other projection kinds may retain +/// unrelated hidden placements and must not acquire this additional restriction. +pub fn state_machine_contains(p: &Project, machine: Id, element: Id) -> bool { + let Some(owner) = p + .elements + .get(&machine) + .filter(|e| e.kind() == Kind::StateMachine) + else { + return false; + }; + p.elements.get(&element).is_some_and(|e| { + e.layer() == owner.layer() + && (matches!(e.kind(), Kind::StateMachine | Kind::Region) || e.kind().is_state()) + && validation::ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine) + }) +} + +/// Fixed body bounds for UML-style state symbols. Their authored position is +/// the top-left of this body; labels and larger pointer targets are derived UI. +/// A State or Mode uses its authored rectangular size instead. +pub fn state_symbol_size(kind: Kind) -> Option { + let (width, height) = match kind { + Kind::Initial => (16., 16.), + Kind::Junction => (10., 10.), + Kind::Fork | Kind::Join => (8., 36.), + Kind::Choice | Kind::ShallowHistory | Kind::DeepHistory => (28., 28.), + Kind::Final | Kind::EntryPoint | Kind::ExitPoint | Kind::Terminate => (24., 24.), + _ => return None, + }; + Some(Size { width, height }) +} + +/// Reserved content before nested Regions. The same boundary is used by layout, +/// routing and the state editor's action/internal-transition rows. +pub fn state_header_height(p: &Project, element: Id) -> f32 { + let Some(e) = p.elements.get(&element) else { + return 64.; + }; + if !e.kind().is_stable_state() { + return 64.; + } + let actions = matches!(e.shared(), Some(SharedBody::State { entry, exit, do_activity, .. }) + if !entry.is_empty() || !exit.is_empty() || !do_activity.is_empty()); + let internal = p + .relations + .values() + .filter(|r| { + matches!(&r.body, + RelationBody::StateTransition { source, transition_kind: TransitionKind::Internal, .. } + if *source == element) + }) + .count(); + 64. + if actions { 28. } else { 0. } + internal as f32 * 56. +} + +pub(crate) fn node_size(p: &Project, e: &Element, size: Size, collapsed: bool) -> Size { + if let Some(size) = state_symbol_size(e.kind()) { + return size; + } + Size { + width: size.width.max(if e.kind().is_stable_state() { + 184. + } else { + 224. + }), + height: size.height.max(if collapsed { + 94. + } else { + state_header_height(p, e.id) + 20. + }), + } +} + +pub(super) fn snap_boundaries(p: &Project, nodes: &mut [ProjectedNode]) { + let geometry: BTreeMap<_, _> = nodes + .iter() + .map(|n| (n.element, (n.position, n.size))) + .collect(); + for node in nodes { + let Some(e) = p.elements.get(&node.element) else { + continue; + }; + if !matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint) { + continue; + } + let boundary = e + .parent + .and_then(|id| p.elements.get(&id)) + .and_then(|region| region.parent); + let Some((position, size)) = boundary.and_then(|id| geometry.get(&id)) else { + // Partial views may omit the containing boundary. Do not invent a + // placement or silently attach the symbol to a different container. + continue; + }; + node.position.x = position.x + + if e.kind() == Kind::ExitPoint { + size.width + } else { + 0. + } + - node.size.width / 2.; + let top = position.y + boundary.map_or(64., |id| state_header_height(p, id)); + let bottom = (position.y + size.height - node.size.height - 12.).max(top); + node.position.y = node.position.y.clamp(top, bottom); + } +} + +// Entry/Exit positions are attached to a containing State/Machine, not the +// Region that owns them semantically. Moving only that Region changes the +// symbol's authored position but does not translate its snapped boundary. +pub(super) fn boundary_moves_with( + p: &Project, + d: &Diagram, + edge: Id, + delta: &Position, + deltas: &BTreeMap, +) -> bool { + let Some(r) = p.relations.get(&edge) else { + return true; + }; + [r.source(), r.target()].into_iter().all(|id| { + let Some(e) = p.elements.get(&id) else { + return true; + }; + if !matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint) { + return true; + } + let boundary = e + .parent + .and_then(|id| p.elements.get(&id)) + .and_then(|region| region.parent); + boundary + .and_then(|id| d.nodes.values().find(|n| n.element == id)) + .is_none_or(|node| deltas.get(&node.id) == Some(delta)) + }) +} diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index c1a6fd95..d2a8f002 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -181,6 +181,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) { }); } for d in p.diagrams.values_mut() { + if let ProjectionKind::StateMachine { machine } = &mut d.projection + && *machine == old + { + *machine = new; + } for route in &mut d.routes { if route.edge == old { route.edge = new; @@ -280,6 +285,12 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool) } } for d in p.diagrams.values() { + if d.projection + .state_machine() + .is_some_and(|machine| removed.contains(&machine)) + { + removed.insert(d.id); + } for n in d.nodes.values() { if removed.contains(&d.id) || removed.contains(&n.element) { removed.insert(n.id); diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs index 5765e4c8..4bd72b27 100644 --- a/crates/oa-core/src/routing.rs +++ b/crates/oa-core/src/routing.rs @@ -338,6 +338,19 @@ fn route_diagram_impl( let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect(); let rectangles: Vec<_> = nodes.values().map(|n| Rect::node(n)).collect(); let authored: BTreeMap<_, _> = diagram.routes.iter().map(|r| (r.edge, r)).collect(); + let headers: BTreeMap<_, _> = nodes + .values() + .map(|node| { + ( + node.id, + if diagram.projection.state_machine().is_some() && !node.collapsed { + projection::state_header_height(project, node.element) + } else { + HEADER + }, + ) + }) + .collect(); let mut ordered: Vec<_> = view.edges.iter().collect(); ordered.sort_by_key(|e| e.id); let mut edges = BTreeMap::new(); @@ -379,7 +392,8 @@ fn route_diagram_impl( let custom_suspended = promoted; let route = if promoted { None } else { stored }; let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone()); - let obstacles = edge_obstacles(edge, &nodes, &rectangles); + let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers); + let cached = preview.and_then(|(baseline, changed)| { (edge.id == changed) .then(|| baseline.edges.get(&edge.id)) @@ -459,7 +473,7 @@ fn route_diagram_impl( if preview.is_none_or(|(baseline, changed)| { edge.id == changed || !baseline.edges.contains_key(&edge.id) }) { - let obstacles = edge_obstacles(edge, &nodes, &rectangles); + let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers); let authored_label = (!routed.custom_suspended) .then(|| { authored @@ -492,6 +506,7 @@ fn edge_obstacles( edge: &ProjectedEdge, nodes: &BTreeMap, rectangles: &[Rect], + headers: &BTreeMap, ) -> Vec { let mut traversable = BTreeSet::new(); ancestors(edge.source, nodes, &mut traversable); @@ -501,7 +516,7 @@ fn edge_obstacles( .map(|r| { let mut r = *r; if traversable.contains(&r.id) { - r.bottom = r.bottom.min(r.top + HEADER); + r.bottom = r.bottom.min(r.top + headers[&r.id]); } r.inflate(CLEARANCE) }) @@ -564,6 +579,15 @@ fn route_edge( let mut candidates = Vec::new(); for a in endpoints(source, edge.source_port, view) { for b in endpoints(target, edge.target_port, view) { + // A real self-transition 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 + { + continue; + } let estimate = manhattan(a.stub, pins.first().copied().unwrap_or(b.stub)) + pins.windows(2).map(|p| manhattan(p[0], p[1])).sum::() + pins.last().map_or(0.0, |&p| manhattan(p, b.stub)); @@ -1010,10 +1034,76 @@ fn grid_path( (None, false) } +/// Full semantic label shared by state transition routes and internal rows. +/// Expressions remain descriptive; no state-machine execution is implied. +pub fn state_transition_summary(p: &Project, r: &crate::Relation) -> String { + let crate::RelationBody::StateTransition { + transition_kind, + triggers, + trigger, + guard, + effects, + effect, + .. + } = &r.body + else { + return r.name.clone(); + }; + let activation = if !triggers.is_empty() { + triggers + .iter() + .map(|id| p.named(*id)) + .collect::>() + .join(" OR ") + } else if !trigger.trim().is_empty() { + format!("Unresolved trigger: {trigger}") + } else if p + .elements + .get(&r.source()) + .is_some_and(|e| e.kind().is_stable_state()) + { + "Completion".into() + } else { + "Transient routing".into() + }; + let mut result = format!("{transition_kind:?} \u{b7} {activation}"); + if !triggers.is_empty() && !trigger.is_empty() { + result.push_str(&format!(" \u{b7} {trigger}")); + } + if !guard.is_empty() { + result.push_str(&format!(" [{guard}]")); + } + if !effects.is_empty() { + result.push_str(&format!( + " / {}", + effects + .iter() + .map(|id| p.named(*id)) + .collect::>() + .join(", ") + )); + } + if !effect.is_empty() { + result.push_str(&format!(" / {effect}")); + } + result +} + fn label_text(project: &Project, edge: Id) -> String { let Some(relation) = project.relations.get(&edge) else { return String::new(); }; + if relation.kind() == RelationKind::StateTransition { + let text = state_transition_summary(project, relation); + return if text.chars().count() > 48 { + text.chars() + .take(47) + .chain(std::iter::once('\u{2026}')) + .collect() + } else { + text + }; + } let text = match relation.kind() { RelationKind::Interaction | RelationKind::FunctionalExchange diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index 5eb2c64a..84d1395d 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -908,9 +908,19 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> { if !seen.insert(id) { return Err(ModelError("Diagram containment cycle.".into())); } - let own = crate::projection::minimum_node_size(p, d.nodes[&id].element, d.nodes[&id].size); + let node = &d.nodes[&id]; + let own = crate::projection::minimum_node_size(p, node.element, node.size); + let own = if d.projection.state_machine().is_some() { + crate::projection::state_node_size(p, &p.elements[&node.element], own, false) + } else { + own + }; let mut width = own.width; - let mut height = 64.; + let mut height = if d.projection.state_machine().is_some() { + crate::projection::state_header_height(p, node.element) + } else { + 64. + }; let kids = children.get(&Some(id)); if let Some(kids) = kids { for child in kids { @@ -935,9 +945,10 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> { children: &BTreeMap, Vec>, sizes: &BTreeMap, positions: &mut BTreeMap, + headers: &BTreeMap, ) { positions.insert(id, position); - let mut y = position.y + 64.; + let mut y = position.y + headers[&id]; if let Some(kids) = children.get(&Some(id)) { for child in kids { place( @@ -949,6 +960,7 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> { children, sizes, positions, + headers, ); y += sizes[child].height + 16.; } @@ -1006,6 +1018,20 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> { for id in &roots { measure(p, *id, d, &children, &mut sizes, &mut seen)?; } + let headers = d + .nodes + .values() + .map(|n| { + ( + n.id, + if d.projection.state_machine().is_some() { + crate::projection::state_header_height(p, n.element) + } else { + 64. + }, + ) + }) + .collect(); let mut positions = BTreeMap::new(); let mut x = 60.; let mut y = 60.; @@ -1016,7 +1042,14 @@ fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> { y += row_height + 60.; row_height = 0.; } - place(*id, Position { x, y }, &children, &sizes, &mut positions); + place( + *id, + Position { x, y }, + &children, + &sizes, + &mut positions, + &headers, + ); x += sizes[id].width + 240.; row_height = row_height.max(sizes[id].height); } diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index daf2cbf2..d8315383 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -658,6 +658,20 @@ pub fn validate(p: &Project) -> Vec { } text(&mut out, d.id, "name", &d.name); text(&mut out, d.id, "description", &d.description); + if let Some(machine) = d.projection.state_machine() + && !p + .elements + .get(&machine) + .is_some_and(|e| e.kind() == Kind::StateMachine && e.layer() == d.layer) + { + error( + &mut out, + d.id, + "state_diagram_context", + "projection", + "State diagram context must reference an existing state machine in the diagram layer.", + ); + } let mut routed_edges = BTreeSet::new(); for (index, route) in d.routes.iter().enumerate() { if !routed_edges.insert(route.edge) { @@ -678,6 +692,21 @@ pub fn validate(p: &Project) -> Vec { "Route edge must reference an existing relation or trace.", ); } + if let Some(machine) = d.projection.state_machine() + && !p.relations.get(&route.edge).is_some_and(|r| { + r.kind() == RelationKind::StateTransition + && crate::projection::state_machine_contains(p, machine, r.source()) + && crate::projection::state_machine_contains(p, machine, r.target()) + }) + { + error( + &mut out, + d.id, + "state_diagram_route", + "routes", + "State diagram routes belong to transitions in its machine. Remove or move incompatible routes in the same transaction.", + ); + } if route.waypoints.len() > MAX_ROUTE_POINTS { error( &mut out, @@ -795,6 +824,17 @@ pub fn validate(p: &Project) -> Vec { "Diagram nodes reference existing elements in the selected layer.", ), }; + if let Some(machine) = d.projection.state_machine() + && !crate::projection::state_machine_contains(p, machine, n.element) + { + error( + &mut out, + n.id, + "state_diagram_member", + "element", + "State diagrams contain only their machine, its regions and vertices. Remove or relocate incompatible placements in the same transaction.", + ); + } for (value, field) in [ (n.position.x, "x"), (n.position.y, "y"), diff --git a/crates/oa-core/tests/state_diagrams.rs b/crates/oa-core/tests/state_diagrams.rs new file mode 100644 index 00000000..a9b89534 --- /dev/null +++ b/crates/oa-core/tests/state_diagrams.rs @@ -0,0 +1,744 @@ +use oa_core::{projection::*, routing::*, source_project::*, *}; +use std::collections::BTreeMap; + +fn point(x: f32, y: f32) -> Position { + Position { x, y } +} +fn add(p: &mut Project, kind: Kind, name: &str, parent: Option) -> Id { + p.add_element(kind, Layer::Oa, name, parent).unwrap() +} +fn view(p: &mut Project, machine: Id, alias: &str) -> Id { + let id = Id::new_v4(); + p.diagrams.insert( + id, + Diagram { + id, + alias: alias.into(), + name: alias.into(), + description: String::new(), + layer: Layer::Oa, + projection: ProjectionKind::StateMachine { machine }, + nodes: BTreeMap::new(), + routes: vec![], + }, + ); + id +} +fn node(p: &Project, d: Id, e: Id) -> Id { + p.diagrams[&d] + .nodes + .values() + .find(|n| n.element == e) + .unwrap() + .id +} +fn place(p: &mut Project, d: Id, e: Id, x: f32, y: f32, w: f32, h: f32) -> Id { + let id = p.place(d, e, point(x, y)); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&id) + .unwrap() + .size = Size { + width: w, + height: h, + }; + id +} +fn transition(p: &mut Project, a: Id, b: Id, kind: TransitionKind) -> Id { + let id = p.relate(RelationKind::StateTransition, "Transition", a, b); + if let RelationBody::StateTransition { + transition_kind, .. + } = &mut p.relations.get_mut(&id).unwrap().body + { + *transition_kind = kind; + } + id +} +fn code(p: &Project, expected: &str) { + assert!( + validation::validate(p).iter().any(|i| i.code == expected), + "{:?}", + validation::validate(p) + ); +} +fn orthogonal(points: &[Position]) { + assert!( + points + .windows(2) + .all(|p| p[0].x == p[1].x || p[0].y == p[1].y), + "{points:?}" + ); +} +struct Fixture { + p: Project, + machine: Id, + region: Id, + a: Id, + b: Id, + inner: Id, + leaf: Id, + entry: Id, + exit: Id, + d: Id, + external: Id, + self_edge: Id, + internal: Id, + local: Id, +} +fn fixture() -> Fixture { + let mut p = Project::empty(); + let performer = add(&mut p, Kind::Entity, "Crew", None); + let machine = add( + &mut p, + Kind::StateMachine, + "Operating modes", + Some(performer), + ); + let region = add(&mut p, Kind::Region, "Main region", Some(machine)); + let initial = add(&mut p, Kind::Initial, "Start", Some(region)); + let a = add(&mut p, Kind::State, "Operating", Some(region)); + let b = add(&mut p, Kind::State, "Standby", Some(region)); + let inner = add(&mut p, Kind::Region, "Nested region", Some(a)); + let leaf = add(&mut p, Kind::State, "Working", Some(inner)); + let entry = add(&mut p, Kind::EntryPoint, "Enter", Some(inner)); + let exit = add(&mut p, Kind::ExitPoint, "Exit", Some(inner)); + transition(&mut p, initial, a, TransitionKind::External); + let external = transition(&mut p, a, b, TransitionKind::External); + if let RelationBody::StateTransition { guard, .. } = + &mut p.relations.get_mut(&external).unwrap().body + { + *guard = "ready".into(); + } + let self_edge = transition(&mut p, b, b, TransitionKind::External); + let internal = transition(&mut p, a, a, TransitionKind::Internal); + let local = transition(&mut p, entry, leaf, TransitionKind::Local); + transition(&mut p, leaf, exit, TransitionKind::External); + let d = view(&mut p, machine, "operating_view"); + for (e, x, y, w, h) in [ + (machine, 0., 0., 1100., 900.), + (region, 24., 64., 1000., 800.), + (initial, 60., 160., 224., 94.), + (a, 180., 180., 440., 500.), + (b, 760., 180., 224., 94.), + (inner, 204., 320., 380., 300.), + (leaf, 310., 420., 224., 94.), + (entry, -200., 390., 224., 94.), + (exit, 2000., 550., 224., 94.), + ] { + place(&mut p, d, e, x, y, w, h); + } + validate_candidate(&p).unwrap(); + Fixture { + p, + machine, + region, + a, + b, + inner, + leaf, + entry, + exit, + d, + external, + self_edge, + internal, + local, + } +} + +#[test] +fn strict_context_wire_source_and_admission_contract() { + let f = fixture(); + let json = serde_json::json!({"StateMachine":{"machine":f.machine}}); + assert_eq!( + serde_json::to_value(f.p.diagrams[&f.d].projection).unwrap(), + json + ); + assert_eq!( + serde_json::from_value::(serde_json::json!("Overview")).unwrap(), + ProjectionKind::Overview + ); + for invalid in [ + serde_json::json!("StateMachine"), + serde_json::json!({"StateMachine":{}}), + serde_json::json!({"StateMachine":{"machine":null}}), + serde_json::json!({"StateMachine":{"machine":f.machine,"extra":1}}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } + let source = language::format(&f.p); + assert!( + source.contains( + "projection {\n StateMachine {\n machine @operating_modes;" + ), + "{source}" + ); + assert_eq!(language::parse(&source).unwrap(), f.p); + assert!( + language::parse(&source.replace( + "machine @operating_modes;", + "machine @operating_modes; extra 1;" + )) + .is_err() + ); + let mut limits = language::ParseLimits::UNLIMITED; + limits.max_records = f.p.identities().len() - 1; + assert!(language::parse_limited(&source, limits).is_err()); + assert_eq!( + serde_json::from_slice::(&serde_json::to_vec(&f.p).unwrap()).unwrap(), + f.p + ); +} + +#[test] +fn contexts_restrict_membership_without_changing_legacy_hidden_placements() { + let mut f = fixture(); + let other = add(&mut f.p, Kind::StateMachine, "Other machine", None); + let foreign_region = add(&mut f.p, Kind::Region, "Other region", Some(other)); + let foreign = add(&mut f.p, Kind::State, "Other state", Some(foreign_region)); + assert!(state_machine_contains(&f.p, f.machine, f.leaf)); + assert!(!state_machine_contains(&f.p, f.machine, foreign)); + let foreign_node = f.p.place(f.d, foreign, point(0., 0.)); + code(&f.p, "state_diagram_member"); + assert!( + !project_view(&f.p, f.d) + .nodes + .iter() + .any(|n| n.id == foreign_node) + ); + f.p.diagrams.get_mut(&f.d).unwrap().projection = ProjectionKind::FunctionalDataflow; + validate_candidate(&f.p).unwrap(); + assert!(project_view(&f.p, f.d).nodes.is_empty()); + for machine in [Id::nil(), f.region] { + f.p.diagrams.get_mut(&f.d).unwrap().projection = ProjectionKind::StateMachine { machine }; + code(&f.p, "state_diagram_context"); + } + f.p.diagrams.get_mut(&f.d).unwrap().projection = + ProjectionKind::StateMachine { machine: f.machine }; + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .remove(&foreign_node); + f.p.diagrams.get_mut(&f.d).unwrap().layer = Layer::Sa; + code(&f.p, "state_diagram_context"); +} + +#[test] +fn empty_and_partial_views_are_read_only_and_omit_unrelated_edges() { + let mut f = fixture(); + let empty = view(&mut f.p, f.machine, "empty_state_view"); + validate_candidate(&f.p).unwrap(); + let before = f.p.clone(); + assert!(project_view(&f.p, empty).nodes.is_empty()); + assert_eq!(before, f.p); + let unrelated = + f.p.relate(RelationKind::Availability, "Availability", f.a, f.b); + let partial = f.p.diagrams.get_mut(&f.d).unwrap(); + partial.nodes.retain(|_, n| [f.a, f.b].contains(&n.element)); + let before = f.p.clone(); + let projected = project_view(&f.p, f.d); + let _ = route_diagram(&f.p, &f.p.diagrams[&f.d], &projected); + assert_eq!(before, f.p); + assert!( + !projected + .edges + .iter() + .any(|e| e.id == unrelated || e.id == f.internal) + ); + assert!(projected.edges.iter().any(|e| e.id == f.self_edge)); + assert!(projected.nodes.iter().all(|n| n.visual_parent.is_none())); +} + +#[test] +fn real_state_symbols_boundaries_internal_rows_and_self_loops_share_geometry() { + let f = fixture(); + let projected = project_view(&f.p, f.d); + let bounds = |id| projected.nodes.iter().find(|n| n.element == id).unwrap(); + let a = bounds(f.a); + let entry = bounds(f.entry); + let exit = bounds(f.exit); + assert_eq!(entry.size, state_symbol_size(Kind::EntryPoint).unwrap()); + assert_eq!(entry.position.x + entry.size.width / 2., a.position.x); + assert_eq!( + exit.position.x + exit.size.width / 2., + a.position.x + a.size.width + ); + assert!(entry.position.y >= a.position.y + state_header_height(&f.p, f.a)); + assert_eq!(entry.visual_parent, Some(node(&f.p, f.d, f.inner))); + assert_eq!(state_header_height(&f.p, f.a), 120.); + assert!(!projected.edges.iter().any(|e| e.id == f.internal)); + assert!(projected.edges.iter().any(|e| e.id == f.local)); + let routed = route_diagram(&f.p, &f.p.diagrams[&f.d], &projected); + let path = &routed.edges[&f.self_edge]; + orthogonal(&path.points); + assert_ne!( + path.points.first(), + path.points.last(), + "Self-transition returns at a different boundary anchor" + ); + assert!(path.points.len() >= 5, "{:?}", path.points); + assert!(path.warnings.is_empty(), "{:?}", path.warnings); + let b = bounds(f.b); + assert!(path.points.iter().any(|p| p.x < b.position.x + || p.x > b.position.x + b.size.width + || p.y < b.position.y + || p.y > b.position.y + b.size.height)); + assert!(routed.edges[&f.external].label.text.contains("[ready]")); + for edge in routed.edges.values() { + orthogonal(&edge.points); + } +} + +#[test] +fn folding_retains_authored_routes_without_false_self_transitions() { + let mut f = fixture(); + let hidden_self = transition(&mut f.p, f.leaf, f.leaf, TransitionKind::External); + let out = transition(&mut f.p, f.leaf, f.b, TransitionKind::External); + let pins = vec![point(690., 480.), point(690., 230.)]; + f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { + edge: out, + waypoints: pins.clone(), + label: None, + }); + let a = node(&f.p, f.d, f.a); + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&a) + .unwrap() + .collapsed = true; + let projected = project_view(&f.p, f.d); + assert!( + !projected + .edges + .iter() + .any(|e| e.id == hidden_self || e.id == f.local) + ); + let routed = route_diagram(&f.p, &f.p.diagrams[&f.d], &projected); + assert!(routed.edges[&out].custom_suspended); + assert!(routed.edges[&out].pins.is_empty()); + assert_eq!(f.p.diagrams[&f.d].routes[0].waypoints, pins); + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&a) + .unwrap() + .collapsed = false; + let restored = route_diagram(&f.p, &f.p.diagrams[&f.d], &project_view(&f.p, f.d)); + assert_eq!(restored.edges[&out].pins, pins); +} + +#[test] +fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() { + let mut f = fixture(); + let copied = view(&mut f.p, f.machine, "second_view"); + for n in f.p.diagrams[&f.d] + .nodes + .values() + .cloned() + .collect::>() + { + place( + &mut f.p, + copied, + n.element, + n.position.x, + n.position.y, + n.size.width, + n.size.height, + ); + } + let moved = node(&f.p, f.d, f.a); + let label = EdgeLabel { + fraction: 0.4, + offset: point(3., -9.), + }; + let pins = vec![point(570., 460.)]; + f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { + edge: f.local, + waypoints: pins.clone(), + label: Some(label.clone()), + }); + f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { + edge: f.external, + waypoints: vec![point(690., 230.)], + label: None, + }); + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .nodes + .get_mut(&moved) + .unwrap() + .collapsed = true; + let before = f.p.clone(); + let mut session = Session::try_new(f.p).unwrap(); + session + .execute(Command::MoveNode { + diagram: f.d, + node: moved, + position: point(220., 240.), + with_descendants: true, + }) + .unwrap(); + let after = session.project(); + assert_eq!(after.diagrams[&copied], before.diagrams[&copied]); + for e in [f.a, f.inner, f.leaf, f.entry, f.exit] { + let n = node(&before, f.d, e); + let old = before.diagrams[&f.d].nodes[&n].position; + assert_eq!( + after.diagrams[&f.d].nodes[&n].position, + point(old.x + 40., old.y + 60.) + ); + assert_eq!(after.elements[&e], before.elements[&e]); + } + assert_eq!( + after.diagrams[&f.d].routes[0].waypoints, + vec![point(610., 520.)] + ); + assert_eq!(after.diagrams[&f.d].routes[0].label, Some(label)); + assert_eq!( + after.diagrams[&f.d].routes[1], + before.diagrams[&f.d].routes[1] + ); + let after = after.clone(); + session.undo(); + assert_eq!(session.project(), &before); + session.redo(); + assert_eq!(session.project(), &after); +} + +#[test] +fn cross_machine_reparent_requires_atomic_placement_and_route_repair() { + let mut f = fixture(); + let orphan = add(&mut f.p, Kind::State, "Independent", Some(f.region)); + let edge = transition(&mut f.p, orphan, orphan, TransitionKind::External); + let placement = f.p.place(f.d, orphan, point(30., 750.)); + f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { + edge, + waypoints: vec![], + label: None, + }); + let machine = add(&mut f.p, Kind::StateMachine, "Other machine", None); + let region = add(&mut f.p, Kind::Region, "Other region", Some(machine)); + let other = view(&mut f.p, machine, "other_view"); + let before = f.p.clone(); + assert!( + prepare_command( + &f.p, + &Command::Reparent { + element: orphan, + parent: Some(region) + } + ) + .is_err() + ); + assert_eq!(f.p, before); + let repaired = prepare_command( + &f.p, + &Command::Batch(vec![ + Command::Reparent { + element: orphan, + parent: Some(region), + }, + Command::RelocateNode { + node: placement, + diagram: other, + }, + Command::RemoveEdgeRoute { diagram: f.d, edge }, + Command::SetEdgeRoute { + diagram: other, + route: EdgeRoute { + edge, + waypoints: vec![], + label: None, + }, + }, + ]), + ) + .unwrap() + .project; + assert_eq!(repaired.diagrams[&other].nodes[&placement].element, orphan); + assert!(!repaired.diagrams[&f.d].nodes.contains_key(&placement)); + assert!(repaired.diagrams[&f.d].routes.is_empty()); +} + +#[test] +fn remapping_and_machine_deletion_include_dedicated_views_and_dependents() { + let mut f = fixture(); + let sa_owner = + f.p.add_element(Kind::System, Layer::Sa, "SA system", None) + .unwrap(); + let counterpart = + f.p.add_element(Kind::StateMachine, Layer::Sa, "SA modes", Some(sa_owner)) + .unwrap(); + let trace = Id::new_v4(); + f.p.traces.insert( + trace, + Trace { + id: trace, + alias: "machine_trace".into(), + name: "Machine trace".into(), + description: String::new(), + oa: f.machine, + sa: counterpart, + kind: TraceKind::State, + baseline: None, + }, + ); + let artifact = Id::new_v4(); + f.p.artifact_links.insert( + artifact, + ArtifactLink { + id: artifact, + alias: "view_review".into(), + name: "View review".into(), + description: String::new(), + owner: f.d, + relation: ArtifactRelation::References, + uri: "file:///review.txt".into(), + revision: None, + fragment: None, + }, + ); + validate_candidate(&f.p).unwrap(); + let renamed = Id::new_v4(); + let remapped = prepare_command(&f.p, &Command::Reidentify(f.machine, renamed)) + .unwrap() + .project; + assert_eq!( + remapped.diagrams[&f.d].projection.state_machine(), + Some(renamed) + ); + assert_eq!(remapped.elements[&f.region].parent, Some(renamed)); + let impact = references::deletion_impact(&remapped, renamed); + for id in [f.d, artifact, trace, node(&remapped, f.d, f.a)] { + assert!(impact.contains(&id)); + } + assert!(!impact.contains(&counterpart)); + let deleted = prepare_command(&remapped, &Command::Delete(renamed)) + .unwrap() + .project; + assert!(!deleted.diagrams.contains_key(&f.d)); + assert!(deleted.elements.contains_key(&counterpart)); + let hidden = prepare_command(&f.p, &Command::Delete(node(&f.p, f.d, f.machine))) + .unwrap() + .project; + assert!(hidden.diagrams.contains_key(&f.d)); + assert!(hidden.elements.contains_key(&f.machine)); +} + +#[test] +fn source_updates_preserve_context_identity_and_unrelated_authored_bytes() { + let f = fixture(); + let mut bundle = format_bundle(&f.p); + bundle + .files + .get_mut(&bundle.entry.clone()) + .unwrap() + .insert_str(0, "// state source rationale\n"); + let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap(); + let fresh = Id::new_v4(); + let prepared = prepare_command( + &f.p, + &Command::Batch(vec![ + Command::Reidentify(f.machine, fresh), + Command::SetAlias(fresh, "reviewed_modes".into()), + ]), + ) + .unwrap(); + let updated = update_bundle_with_identity_map( + &original, + &prepared.project, + &prepared.identity_map, + ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(updated.decoded.project, prepared.project); + assert!( + updated + .bundle + .files + .values() + .any(|s| s.contains("machine @reviewed_modes;")) + ); + assert!(updated.bundle.files[&bundle.entry].starts_with("// state source rationale\n")); + assert_eq!( + compile_bundle(&updated.bundle, ProjectLimits::default()) + .unwrap() + .bundle, + updated.bundle + ); +} + +#[test] +fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() { + let mut f = fixture(); + let pins = vec![point(730., 130.), point(1060., 130.)]; + f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { + edge: f.self_edge, + waypoints: pins.clone(), + label: Some(EdgeLabel { + fraction: 0.25, + offset: point(11., 7.), + }), + }); + let before = f.p.clone(); + let routed = route_diagram(&f.p, &f.p.diagrams[&f.d], &project_view(&f.p, f.d)); + assert_eq!(routed.edges[&f.self_edge].pins, pins); + let mut previous = 0; + for pin in &pins { + let i = routed.edges[&f.self_edge] + .points + .iter() + .enumerate() + .skip(previous) + .find(|(_, p)| *p == pin) + .unwrap() + .0; + previous = i + 1; + } + assert_eq!(f.p, before); + let laid = prepare_command(&f.p, &Command::Layout(f.d)) + .unwrap() + .project; + let nodes = &laid.diagrams[&f.d].nodes; + let a = &nodes[&node(&laid, f.d, f.a)]; + let inner = &nodes[&node(&laid, f.d, f.inner)]; + assert!(inner.position.y >= a.position.y + state_header_height(&laid, f.a)); + let first = language::format(&laid); + assert_eq!(language::parse(&first).unwrap(), laid); +} + +#[test] +fn reconnect_keeps_identity_and_validates_replacement_without_double_counting() { + let f = fixture(); + let initial = + f.p.elements + .values() + .find(|e| e.kind() == Kind::Initial) + .unwrap() + .id; + let edge = + f.p.relations + .values() + .find(|r| r.source() == initial) + .unwrap(); + let mut replacement = edge.clone(); + if let RelationBody::StateTransition { target, .. } = &mut replacement.body { + *target = f.b; + } + let after = prepare_command(&f.p, &Command::EditRelation(edge.id, replacement.clone())) + .unwrap() + .project; + assert_eq!(after.relations[&edge.id], replacement); + let mut invalid = replacement.clone(); + if let RelationBody::StateTransition { target, .. } = &mut invalid.body { + *target = initial; + } + assert!(prepare_command(&after, &Command::EditRelation(edge.id, invalid)).is_err()); + assert_eq!(after.relations[&edge.id], replacement); +} + +#[test] +fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_boundary() { + let mut f = fixture(); + let pins = vec![point(280., 390.), point(280., 470.)]; + f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { + edge: f.local, + waypoints: pins.clone(), + label: None, + }); + let before = project_view(&f.p, f.d); + let entry_before = before + .nodes + .iter() + .find(|n| n.element == f.entry) + .unwrap() + .position; + let region = node(&f.p, f.d, f.inner); + let old = f.p.diagrams[&f.d].nodes[®ion].position; + let after = prepare_command( + &f.p, + &Command::MoveNode { + diagram: f.d, + node: region, + position: point(old.x + 10., old.y + 10.), + with_descendants: true, + }, + ) + .unwrap() + .project; + let projected = project_view(&after, f.d); + let entry_after = projected + .nodes + .iter() + .find(|n| n.element == f.entry) + .unwrap() + .position; + assert_eq!( + entry_after.x, entry_before.x, + "Entry stays on the unmoved composite boundary" + ); + assert_eq!(after.diagrams[&f.d].routes[0].waypoints, pins); +} + +#[test] +fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_geometry() { + let mut f = fixture(); + let initial = + f.p.elements + .values() + .find(|e| e.kind() == Kind::Initial) + .unwrap() + .id; + let edge = + f.p.relations + .values() + .find(|r| r.source() == initial) + .unwrap() + .id; + let projected = project_view(&f.p, f.d); + let n = projected + .nodes + .iter() + .find(|n| n.element == initial) + .unwrap(); + assert_eq!( + n.size, + Size { + width: 16., + height: 16. + } + ); + let settled = route_diagram(&f.p, &f.p.diagrams[&f.d], &projected); + let first = settled.edges[&edge].points[0]; + assert!( + [ + point(n.position.x, n.position.y + 8.), + point(n.position.x + 16., n.position.y + 8.), + point(n.position.x + 8., n.position.y), + point(n.position.x + 8., n.position.y + 16.) + ] + .contains(&first) + ); + f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute { + edge: f.self_edge, + waypoints: vec![], + label: Some(EdgeLabel { + fraction: 0.3, + offset: point(11., 9.), + }), + }); + let preview = route_edge_preview(&f.p, &f.p.diagrams[&f.d], &projected, &settled, f.self_edge); + assert_eq!( + preview.edges[&f.self_edge].points, + settled.edges[&f.self_edge].points + ); + assert_eq!(preview.edges[&edge], settled.edges[&edge]); +}