1335 lines
44 KiB
Rust
1335 lines
44 KiB
Rust
//! Pure planners for direct editing of persisted state-machine diagrams.
|
|
//!
|
|
//! The application owns tool state and draft guards. It must supply its current
|
|
//! document/diagram/revision/epoch context, execute a returned command once, and
|
|
//! apply the selection metadata only after the document transaction succeeds.
|
|
pub use crate::route_edit::Context;
|
|
use oa_core::{projection, *};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Endpoint {
|
|
Source,
|
|
Target,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Tool {
|
|
Select,
|
|
Vertex(Kind),
|
|
Region,
|
|
Transition,
|
|
Reconnect { relation: Id, endpoint: Endpoint },
|
|
AdjustAttachment { relation: Id, end: EdgeEnd },
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Selection(crate::app::multiselect::Event),
|
|
Remove(crate::app::diagram_removal::Intent),
|
|
Choose(Tool),
|
|
CreateView(Id),
|
|
Select(Context, Id),
|
|
Pan(Context, iced::Vector),
|
|
Zoom(Context, f32),
|
|
Viewport(Context, iced::Size),
|
|
Fit(Context),
|
|
EditTransition(Context, Id),
|
|
ContextMenu {
|
|
context: Context,
|
|
object: Id,
|
|
placement: Option<Id>,
|
|
selection_target: Option<crate::diagram_usage::Target>,
|
|
anchor: iced::Point,
|
|
bend: Option<usize>,
|
|
route_segment: Option<(usize, Position)>,
|
|
},
|
|
CreateAt {
|
|
context: Context,
|
|
owner: Id,
|
|
kind: Kind,
|
|
position: Position,
|
|
},
|
|
Place {
|
|
context: Context,
|
|
element: Id,
|
|
position: Position,
|
|
},
|
|
Move {
|
|
context: Context,
|
|
node: Id,
|
|
position: Position,
|
|
},
|
|
Connect {
|
|
context: Context,
|
|
source: Id,
|
|
target: Id,
|
|
},
|
|
Reconnect {
|
|
context: Context,
|
|
relation: Id,
|
|
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 {
|
|
pub fn context(&self) -> Option<Context> {
|
|
match self {
|
|
Self::Remove(intent) => Some(intent.context),
|
|
// Group events carry their own exact selection envelope.
|
|
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
|
|
Self::Select(c, _)
|
|
| Self::Pan(c, _)
|
|
| Self::Zoom(c, _)
|
|
| Self::Viewport(c, _)
|
|
| Self::Fit(c)
|
|
| Self::EditTransition(c, _)
|
|
| Self::ContextMenu { context: c, .. }
|
|
| Self::CreateAt { context: c, .. }
|
|
| Self::Place { context: c, .. }
|
|
| Self::Move { context: c, .. }
|
|
| Self::Connect { context: 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),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Plan {
|
|
pub command: Command,
|
|
pub diagram: Id,
|
|
/// Semantic identity, including after a placement move.
|
|
pub selected: Id,
|
|
pub placement: Option<Id>,
|
|
}
|
|
|
|
fn error(message: impl Into<String>) -> ModelError {
|
|
ModelError(message.into())
|
|
}
|
|
|
|
fn machine(project: &Project, diagram: Id) -> Result<Id, ModelError> {
|
|
let view = project
|
|
.diagrams
|
|
.get(&diagram)
|
|
.ok_or_else(|| error("This state diagram no longer exists."))?;
|
|
let machine = view
|
|
.projection
|
|
.state_machine()
|
|
.ok_or_else(|| error("Choose a dedicated state-machine diagram."))?;
|
|
if !project
|
|
.elements
|
|
.get(&machine)
|
|
.is_some_and(|e| e.kind() == Kind::StateMachine && e.layer() == view.layer)
|
|
{
|
|
return Err(error(
|
|
"The diagram's state-machine context is no longer valid.",
|
|
));
|
|
}
|
|
Ok(machine)
|
|
}
|
|
|
|
/// Returns the diagram's machine only when the captured input is still current.
|
|
/// The caller supplies the actual current context; project UUID is not the
|
|
/// application's document identity (reopening the same project creates a new one).
|
|
pub fn validate_context(
|
|
project: &Project,
|
|
captured: Context,
|
|
current: Context,
|
|
) -> Result<Id, ModelError> {
|
|
if captured != current {
|
|
return Err(error(
|
|
"The state diagram changed. Start this gesture again.",
|
|
));
|
|
}
|
|
machine(project, captured.diagram)
|
|
}
|
|
|
|
pub fn eligible_owner(project: &Project, diagram: Id, owner: Id, kind: Kind) -> bool {
|
|
let Ok(machine) = machine(project, diagram) else {
|
|
return false;
|
|
};
|
|
let Some(parent) = project.elements.get(&owner) else {
|
|
return false;
|
|
};
|
|
projection::state_machine_contains(project, machine, owner)
|
|
&& if kind == Kind::Region {
|
|
parent.kind() == Kind::StateMachine || parent.kind().is_stable_state()
|
|
} else {
|
|
kind.is_state() && parent.kind() == Kind::Region
|
|
}
|
|
&& validation::can_parent(project, kind, parent.layer(), Some(owner))
|
|
}
|
|
|
|
/// Semantic objects without a placement, including missing Regions and Machine.
|
|
/// Folded placements are retained and therefore are not offered as missing.
|
|
pub fn unplaced(project: &Project, diagram: Id) -> Vec<Id> {
|
|
let Ok(machine) = machine(project, diagram) else {
|
|
return vec![];
|
|
};
|
|
let placed: BTreeSet<_> = project.diagrams[&diagram]
|
|
.nodes
|
|
.values()
|
|
.map(|n| n.element)
|
|
.collect();
|
|
let mut elements: Vec<_> = project
|
|
.elements
|
|
.values()
|
|
.filter(|e| {
|
|
!placed.contains(&e.id) && projection::state_machine_contains(project, machine, e.id)
|
|
})
|
|
.collect();
|
|
elements.sort_by_key(|e| (&e.name, &e.alias, e.id));
|
|
elements.into_iter().map(|e| e.id).collect()
|
|
}
|
|
|
|
fn visible_vertex(project: &Project, diagram: Id, vertex: Id) -> bool {
|
|
machine(project, diagram).is_ok_and(|machine| {
|
|
projection::state_machine_contains(project, machine, vertex)
|
|
&& project.elements[&vertex].kind().is_state()
|
|
&& projection::project_view(project, diagram)
|
|
.nodes
|
|
.iter()
|
|
.any(|n| n.element == vertex)
|
|
})
|
|
}
|
|
|
|
/// The direct transition tool creates External transitions. EntryPoint routing
|
|
/// requires Local semantics and remains available in the typed transition form.
|
|
pub fn can_start(project: &Project, diagram: Id, vertex: Id) -> bool {
|
|
visible_vertex(project, diagram, vertex)
|
|
&& !matches!(
|
|
project.elements[&vertex].kind(),
|
|
Kind::Final | Kind::Terminate | Kind::EntryPoint
|
|
)
|
|
}
|
|
|
|
pub fn can_connect(project: &Project, diagram: Id, source: Id, target: Id) -> bool {
|
|
can_start(project, diagram, source)
|
|
&& visible_vertex(project, diagram, target)
|
|
&& validation::can_connect(project, RelationKind::StateTransition, source, target)
|
|
}
|
|
|
|
fn reconnect_command(
|
|
project: &Project,
|
|
diagram: Id,
|
|
relation: Id,
|
|
endpoint: Endpoint,
|
|
vertex: Id,
|
|
) -> Result<Command, ModelError> {
|
|
let machine = machine(project, diagram)?;
|
|
let original = project
|
|
.relations
|
|
.get(&relation)
|
|
.filter(|r| {
|
|
r.kind() == RelationKind::StateTransition
|
|
&& projection::state_machine_contains(project, machine, r.source())
|
|
&& projection::state_machine_contains(project, machine, r.target())
|
|
})
|
|
.ok_or_else(|| error("Choose a transition in this state machine."))?;
|
|
if !visible_vertex(project, diagram, vertex) {
|
|
return Err(error(
|
|
"Choose a visible state-machine vertex in this diagram.",
|
|
));
|
|
}
|
|
let mut replacement = original.clone();
|
|
let RelationBody::StateTransition { source, target, .. } = &mut replacement.body else {
|
|
unreachable!("the original relation was checked above")
|
|
};
|
|
match endpoint {
|
|
Endpoint::Source => *source = vertex,
|
|
Endpoint::Target => *target = vertex,
|
|
}
|
|
Ok(Command::EditRelation(relation, replacement))
|
|
}
|
|
|
|
pub fn can_reconnect(
|
|
project: &Project,
|
|
diagram: Id,
|
|
relation: Id,
|
|
endpoint: Endpoint,
|
|
vertex: Id,
|
|
) -> bool {
|
|
reconnect_command(project, diagram, relation, endpoint, vertex)
|
|
.and_then(|command| prepare_command(project, &command))
|
|
.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)
|
|
}
|
|
|
|
fn finite(position: Position) -> Result<(), ModelError> {
|
|
if !position.x.is_finite() || !position.y.is_finite() {
|
|
return Err(error("Choose a finite position in the state diagram."));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn initial_size(project: &Project, element: Id) -> Size {
|
|
let kind = project.elements[&element].kind();
|
|
projection::state_symbol_size(kind).unwrap_or(Size {
|
|
width: if matches!(kind, Kind::StateMachine | Kind::Region) {
|
|
336.
|
|
} else {
|
|
224.
|
|
},
|
|
height: (projection::state_header_height(project, element) + 40.).max(94.),
|
|
})
|
|
}
|
|
|
|
/// One explicit operation initializes all existing contents. Shared Layout
|
|
/// reserves headers/internal rows and uses the same state-symbol geometry.
|
|
pub fn create_view(project: &Project, machine: Id) -> Result<Plan, ModelError> {
|
|
let owner = project
|
|
.elements
|
|
.get(&machine)
|
|
.filter(|e| e.kind() == Kind::StateMachine)
|
|
.ok_or_else(|| error("Choose an existing state machine."))?;
|
|
let diagram = Id::new_v4();
|
|
let name = format!("{} · states", owner.name);
|
|
let mut working = project.clone();
|
|
working.diagrams.insert(
|
|
diagram,
|
|
Diagram {
|
|
id: diagram,
|
|
alias: project.fresh_alias(&name, "state_view", diagram),
|
|
name,
|
|
description: String::new(),
|
|
layer: owner.layer(),
|
|
projection: ProjectionKind::StateMachine { machine },
|
|
nodes: BTreeMap::new(),
|
|
layout: None,
|
|
routes: vec![],
|
|
view_options: Default::default(),
|
|
},
|
|
);
|
|
let members: Vec<_> = project
|
|
.elements
|
|
.keys()
|
|
.copied()
|
|
.filter(|id| projection::state_machine_contains(project, machine, *id))
|
|
.collect();
|
|
for element in members {
|
|
let size = initial_size(&working, element);
|
|
let node = working.place(diagram, element, Position { x: 60., y: 60. });
|
|
working
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&node)
|
|
.unwrap()
|
|
.size = size;
|
|
}
|
|
checked(
|
|
project,
|
|
Plan {
|
|
command: Command::Batch(vec![
|
|
Command::CreateDiagram(working.diagrams.remove(&diagram).unwrap()),
|
|
Command::Layout(diagram),
|
|
]),
|
|
diagram,
|
|
selected: machine,
|
|
placement: None,
|
|
},
|
|
)
|
|
}
|
|
|
|
/// Place the semantic path without changing ownership, other placements, or
|
|
/// other views. Ancestors wrap the requested position using their shared header
|
|
/// heights. Existing ancestors unfold so the accepted child is actually visible.
|
|
fn place_path(
|
|
working: &mut Project,
|
|
diagram: Id,
|
|
element: Id,
|
|
position: Position,
|
|
commands: &mut Vec<Command>,
|
|
) -> Result<Id, ModelError> {
|
|
finite(position)?;
|
|
let machine = machine(working, diagram)?;
|
|
if !projection::state_machine_contains(working, machine, element) {
|
|
return Err(error(
|
|
"Place a region or vertex from this diagram's state machine.",
|
|
));
|
|
}
|
|
let mut path = vec![(element, position)];
|
|
let mut current = element;
|
|
let mut position = position;
|
|
while current != machine {
|
|
let parent = working.elements[¤t]
|
|
.parent
|
|
.ok_or_else(|| error("The state-machine ownership path is incomplete."))?;
|
|
position = Position {
|
|
x: position.x - 24.,
|
|
y: position.y - projection::state_header_height(working, parent),
|
|
};
|
|
path.push((parent, position));
|
|
current = parent;
|
|
}
|
|
let mut placement = None;
|
|
for (element, position) in path.into_iter().rev() {
|
|
let existing = working.diagrams[&diagram]
|
|
.nodes
|
|
.values()
|
|
.find(|n| n.element == element)
|
|
.cloned();
|
|
let id = if let Some(mut node) = existing {
|
|
if node.collapsed {
|
|
node.collapsed = false;
|
|
working
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.insert(node.id, node.clone());
|
|
commands.push(Command::EditNode {
|
|
diagram,
|
|
original: node.id,
|
|
node: node.clone(),
|
|
});
|
|
}
|
|
node.id
|
|
} else {
|
|
let size = initial_size(working, element);
|
|
let id = working.place(diagram, element, position);
|
|
let node = working
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&id)
|
|
.unwrap();
|
|
node.size = size;
|
|
commands.push(Command::CreateNode {
|
|
diagram,
|
|
node: node.clone(),
|
|
});
|
|
id
|
|
};
|
|
placement = Some(id);
|
|
}
|
|
Ok(placement.expect("a membership path includes its requested element"))
|
|
}
|
|
|
|
pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan, ModelError> {
|
|
let captured = event
|
|
.context()
|
|
.ok_or_else(|| error("This request does not edit diagram content."))?;
|
|
validate_context(project, captured, current)?;
|
|
let diagram = captured.diagram;
|
|
let (command, selected, placement) = match *event {
|
|
Event::CreateAt {
|
|
owner,
|
|
kind,
|
|
position,
|
|
..
|
|
} => {
|
|
if !eligible_owner(project, diagram, owner, kind) {
|
|
return Err(error(
|
|
"Create a vertex inside an explicit owning Region, or a Region inside its Machine or stable State/Mode.",
|
|
));
|
|
}
|
|
finite(position)?;
|
|
let element = project
|
|
.make_element(
|
|
kind,
|
|
project.diagrams[&diagram].layer,
|
|
kind.label(),
|
|
Some(owner),
|
|
)
|
|
.ok_or_else(|| error("This owner cannot contain the selected kind."))?;
|
|
let selected = element.id;
|
|
let mut working = project.clone();
|
|
working.elements.insert(selected, element.clone());
|
|
let mut commands = vec![Command::CreateElement(element)];
|
|
let placement = place_path(&mut working, diagram, selected, position, &mut commands)?;
|
|
(Command::Batch(commands), selected, Some(placement))
|
|
}
|
|
Event::Place {
|
|
element, position, ..
|
|
} => {
|
|
let mut working = project.clone();
|
|
let mut commands = vec![];
|
|
let placement = place_path(&mut working, diagram, element, position, &mut commands)?;
|
|
(Command::Batch(commands), element, Some(placement))
|
|
}
|
|
Event::Move { node, position, .. } => {
|
|
finite(position)?;
|
|
let placement = project.diagrams[&diagram]
|
|
.nodes
|
|
.get(&node)
|
|
.ok_or_else(|| error("The placement no longer belongs to this state diagram."))?;
|
|
(
|
|
Command::MoveNode {
|
|
diagram,
|
|
node,
|
|
position,
|
|
with_descendants: true,
|
|
},
|
|
placement.element,
|
|
Some(node),
|
|
)
|
|
}
|
|
Event::Connect { source, target, .. } => {
|
|
if !visible_vertex(project, diagram, source)
|
|
|| !visible_vertex(project, diagram, target)
|
|
{
|
|
return Err(error("Choose two visible vertices in this state diagram."));
|
|
}
|
|
let id = Id::new_v4();
|
|
let name = format!("{} → {}", project.named(source), project.named(target));
|
|
let relation = Relation {
|
|
id,
|
|
alias: project.fresh_alias(&name, "transition", id),
|
|
name,
|
|
description: String::new(),
|
|
body: RelationBody::new(RelationKind::StateTransition, source, target),
|
|
};
|
|
(Command::CreateRelation(relation), id, None)
|
|
}
|
|
Event::Reconnect {
|
|
relation,
|
|
endpoint,
|
|
vertex,
|
|
..
|
|
} => (
|
|
reconnect_command(project, diagram, relation, endpoint, vertex)?,
|
|
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.",
|
|
));
|
|
}
|
|
};
|
|
checked(
|
|
project,
|
|
Plan {
|
|
command,
|
|
diagram,
|
|
selected,
|
|
placement,
|
|
},
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::document::Document;
|
|
|
|
struct Fixture {
|
|
project: Project,
|
|
machine: Id,
|
|
region: Id,
|
|
initial: Id,
|
|
composite: Id,
|
|
nested: Id,
|
|
leaf: Id,
|
|
target: Id,
|
|
other_machine: Id,
|
|
function: Id,
|
|
}
|
|
|
|
fn add(p: &mut Project, kind: Kind, name: &str, parent: Option<Id>) -> Id {
|
|
p.add_element(kind, Layer::Sa, name, parent).unwrap()
|
|
}
|
|
|
|
fn fixture() -> Fixture {
|
|
let mut project = Project::empty();
|
|
let machine = add(&mut project, Kind::StateMachine, "Operating modes", None);
|
|
let region = add(&mut project, Kind::Region, "Main", Some(machine));
|
|
let initial = add(&mut project, Kind::Initial, "Start", Some(region));
|
|
let composite = add(&mut project, Kind::State, "Operating", Some(region));
|
|
let nested = add(&mut project, Kind::Region, "Work", Some(composite));
|
|
let leaf = add(&mut project, Kind::State, "Working", Some(nested));
|
|
let target = add(&mut project, Kind::State, "Standby", Some(region));
|
|
let other_machine = add(&mut project, Kind::StateMachine, "Other behavior", None);
|
|
let function = add(&mut project, Kind::Function, "Report mode", None);
|
|
Fixture {
|
|
project,
|
|
machine,
|
|
region,
|
|
initial,
|
|
composite,
|
|
nested,
|
|
leaf,
|
|
target,
|
|
other_machine,
|
|
function,
|
|
}
|
|
}
|
|
|
|
fn context(diagram: Id) -> Context {
|
|
Context {
|
|
document: Id::new_v4(),
|
|
diagram,
|
|
revision: 7,
|
|
epoch: 3,
|
|
}
|
|
}
|
|
|
|
fn node(project: &Project, diagram: Id, element: Id) -> Id {
|
|
project.diagrams[&diagram]
|
|
.nodes
|
|
.values()
|
|
.find(|n| n.element == element)
|
|
.unwrap()
|
|
.id
|
|
}
|
|
|
|
fn create(f: &mut Fixture) -> Id {
|
|
let plan = create_view(&f.project, f.machine).unwrap();
|
|
f.project = prepare_command(&f.project, &plan.command).unwrap().project;
|
|
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();
|
|
let before = f.project.clone();
|
|
let plan = create_view(&f.project, f.machine).unwrap();
|
|
assert_eq!(f.project, before, "Planning never mutates the input");
|
|
let mut document = Document::from_project(f.project, Default::default()).unwrap();
|
|
let source = document.bundle().clone();
|
|
document.execute(plan.command).unwrap();
|
|
assert_eq!(document.revision(), 1);
|
|
let view = projection::project_view(document.project(), plan.diagram);
|
|
let members: BTreeSet<_> = view.nodes.iter().map(|n| n.element).collect();
|
|
assert_eq!(
|
|
members,
|
|
BTreeSet::from([
|
|
f.machine,
|
|
f.region,
|
|
f.initial,
|
|
f.composite,
|
|
f.nested,
|
|
f.leaf,
|
|
f.target
|
|
])
|
|
);
|
|
assert!(
|
|
view.ports.is_empty(),
|
|
"State vertices do not become semantic ports"
|
|
);
|
|
assert!(!members.contains(&f.other_machine));
|
|
assert!(!members.contains(&f.function));
|
|
assert_eq!(document.project().elements, before.elements);
|
|
let accepted = document.bundle().clone();
|
|
assert_eq!(
|
|
source_project::compile_bundle(&accepted, Default::default())
|
|
.unwrap()
|
|
.decoded
|
|
.project,
|
|
*document.project()
|
|
);
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &source);
|
|
assert_eq!(document.project(), &before);
|
|
assert!(document.redo());
|
|
assert_eq!(document.bundle(), &accepted);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_machine_has_an_explicit_visible_owner_for_first_region_creation() {
|
|
let mut project = Project::empty();
|
|
let machine = add(&mut project, Kind::StateMachine, "New behavior", None);
|
|
let view = create_view(&project, machine).unwrap();
|
|
let project = prepare_command(&project, &view.command).unwrap().project;
|
|
assert_eq!(
|
|
projection::project_view(&project, view.diagram).nodes[0].element,
|
|
machine
|
|
);
|
|
let c = context(view.diagram);
|
|
let plan = plan(
|
|
&project,
|
|
c,
|
|
&Event::CreateAt {
|
|
context: c,
|
|
owner: machine,
|
|
kind: Kind::Region,
|
|
position: Position { x: 100., y: 160. },
|
|
},
|
|
)
|
|
.unwrap();
|
|
let accepted = prepare_command(&project, &plan.command).unwrap().project;
|
|
assert_eq!(accepted.elements[&plan.selected].parent, Some(machine));
|
|
assert_eq!(accepted.elements[&plan.selected].kind(), Kind::Region);
|
|
}
|
|
|
|
#[test]
|
|
fn creation_places_missing_ancestry_and_unfolds_existing_owners_atomically() {
|
|
let mut f = fixture();
|
|
let diagram = create(&mut f);
|
|
let composite_node = node(&f.project, diagram, f.composite);
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.retain(|_, n| n.element == f.machine || n.element == f.composite);
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&composite_node)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
let old_position = f.project.diagrams[&diagram].nodes[&composite_node].position;
|
|
let before = f.project.clone();
|
|
let c = context(diagram);
|
|
let at = Position { x: 430., y: 510. };
|
|
let plan = plan(
|
|
&f.project,
|
|
c,
|
|
&Event::CreateAt {
|
|
context: c,
|
|
owner: f.nested,
|
|
kind: Kind::Mode,
|
|
position: at,
|
|
},
|
|
)
|
|
.unwrap();
|
|
let mut document = Document::from_project(f.project, Default::default()).unwrap();
|
|
let source = document.bundle().clone();
|
|
document.execute(plan.command).unwrap();
|
|
assert_eq!(document.revision(), 1);
|
|
let p = document.project();
|
|
assert_eq!(p.elements[&plan.selected].parent, Some(f.nested));
|
|
assert_eq!(
|
|
p.diagrams[&diagram].nodes[&plan.placement.unwrap()].position,
|
|
at
|
|
);
|
|
assert_eq!(
|
|
p.diagrams[&diagram].nodes[&composite_node].position,
|
|
old_position
|
|
);
|
|
assert!(!p.diagrams[&diagram].nodes[&composite_node].collapsed);
|
|
for id in [f.machine, f.region, f.composite, f.nested, plan.selected] {
|
|
assert!(
|
|
projection::project_view(p, diagram)
|
|
.nodes
|
|
.iter()
|
|
.any(|n| n.element == id)
|
|
);
|
|
}
|
|
assert_eq!(p.elements.len(), before.elements.len() + 1);
|
|
assert!(
|
|
!p.diagrams[&diagram]
|
|
.nodes
|
|
.values()
|
|
.any(|n| n.element == f.leaf)
|
|
);
|
|
assert!(document.undo());
|
|
assert_eq!(document.bundle(), &source);
|
|
assert_eq!(document.project(), &before);
|
|
}
|
|
|
|
#[test]
|
|
fn placing_existing_content_and_moving_a_folded_group_preserve_other_views_and_semantics() {
|
|
let mut f = fixture();
|
|
let diagram = create(&mut f);
|
|
let other = create(&mut f);
|
|
let leaf_node = node(&f.project, diagram, f.leaf);
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.remove(&leaf_node);
|
|
assert!(unplaced(&f.project, diagram).contains(&f.leaf));
|
|
let c = context(diagram);
|
|
let placement = plan(
|
|
&f.project,
|
|
c,
|
|
&Event::Place {
|
|
context: c,
|
|
element: f.leaf,
|
|
position: Position { x: 280., y: 460. },
|
|
},
|
|
)
|
|
.unwrap();
|
|
let semantics = f.project.elements.clone();
|
|
f.project = prepare_command(&f.project, &placement.command)
|
|
.unwrap()
|
|
.project;
|
|
assert_eq!(f.project.elements, semantics);
|
|
assert!(unplaced(&f.project, diagram).is_empty());
|
|
let edge = f
|
|
.project
|
|
.relate(RelationKind::StateTransition, "Retry", f.leaf, f.leaf);
|
|
let pin = Position { x: 440., y: 540. };
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.routes
|
|
.push(EdgeRoute {
|
|
edge,
|
|
source_attachment: None,
|
|
target_attachment: None,
|
|
waypoints: vec![pin],
|
|
label: None,
|
|
});
|
|
let composite = node(&f.project, diagram, f.composite);
|
|
f.project
|
|
.diagrams
|
|
.get_mut(&diagram)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&composite)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
let before = f.project.clone();
|
|
let old = before.diagrams[&diagram].nodes[&composite].position;
|
|
let plan = plan(
|
|
&f.project,
|
|
c,
|
|
&Event::Move {
|
|
context: c,
|
|
node: composite,
|
|
position: Position {
|
|
x: old.x + 40.,
|
|
y: old.y + 70.,
|
|
},
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(plan.selected, f.composite);
|
|
assert_eq!(plan.placement, Some(composite));
|
|
let mut session = Session::try_new(f.project).unwrap();
|
|
session.execute(plan.command).unwrap();
|
|
let after = session.project();
|
|
for element in [f.composite, f.nested, f.leaf] {
|
|
let id = node(&before, diagram, element);
|
|
let old = before.diagrams[&diagram].nodes[&id].position;
|
|
assert_eq!(
|
|
after.diagrams[&diagram].nodes[&id].position,
|
|
Position {
|
|
x: old.x + 40.,
|
|
y: old.y + 70.
|
|
}
|
|
);
|
|
}
|
|
assert_eq!(after.diagrams[&other], before.diagrams[&other]);
|
|
assert_eq!(after.elements, before.elements);
|
|
assert_eq!(
|
|
after.diagrams[&diagram].routes[0].waypoints,
|
|
vec![Position {
|
|
x: pin.x + 40.,
|
|
y: pin.y + 70.
|
|
}]
|
|
);
|
|
session.undo();
|
|
assert_eq!(session.project(), &before);
|
|
}
|
|
|
|
#[test]
|
|
fn every_context_component_and_invalid_ownership_are_checked_before_planning() {
|
|
let mut f = fixture();
|
|
let diagram = create(&mut f);
|
|
let c = context(diagram);
|
|
let event = Event::CreateAt {
|
|
context: c,
|
|
owner: f.region,
|
|
kind: Kind::State,
|
|
position: Position { x: 200., y: 300. },
|
|
};
|
|
for current in [
|
|
Context {
|
|
document: Id::new_v4(),
|
|
..c
|
|
},
|
|
Context {
|
|
diagram: Id::new_v4(),
|
|
..c
|
|
},
|
|
Context {
|
|
revision: c.revision + 1,
|
|
..c
|
|
},
|
|
Context {
|
|
epoch: c.epoch + 1,
|
|
..c
|
|
},
|
|
] {
|
|
assert!(plan(&f.project, current, &event).is_err());
|
|
}
|
|
let before = f.project.clone();
|
|
for (owner, kind) in [
|
|
(f.machine, Kind::State),
|
|
(f.region, Kind::Region),
|
|
(f.other_machine, Kind::Region),
|
|
(f.region, Kind::Port),
|
|
(f.function, Kind::Region),
|
|
] {
|
|
assert!(!eligible_owner(&f.project, diagram, owner, kind));
|
|
assert!(
|
|
plan(
|
|
&f.project,
|
|
c,
|
|
&Event::CreateAt {
|
|
context: c,
|
|
owner,
|
|
kind,
|
|
position: Position { x: 20., y: 30. }
|
|
}
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
assert!(
|
|
plan(
|
|
&f.project,
|
|
c,
|
|
&Event::CreateAt {
|
|
context: c,
|
|
owner: f.region,
|
|
kind: Kind::Initial,
|
|
position: Position { x: 20., y: 30. }
|
|
}
|
|
)
|
|
.is_err(),
|
|
"A second Initial cannot be partially created or placed"
|
|
);
|
|
assert!(
|
|
plan(
|
|
&f.project,
|
|
c,
|
|
&Event::Move {
|
|
context: c,
|
|
node: node(&f.project, diagram, f.target),
|
|
position: Position { x: f32::NAN, y: 0. }
|
|
}
|
|
)
|
|
.is_err()
|
|
);
|
|
assert_eq!(f.project, before);
|
|
f.project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
|
assert!(validate_context(&f.project, c, c).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn connect_preserves_self_loops_and_rejects_hidden_or_incompatible_vertices() {
|
|
let mut f = fixture();
|
|
let diagram = create(&mut f);
|
|
let c = context(diagram);
|
|
assert!(can_connect(&f.project, diagram, f.target, f.target));
|
|
let self_loop = plan(
|
|
&f.project,
|
|
c,
|
|
&Event::Connect {
|
|
context: c,
|
|
source: f.target,
|
|
target: f.target,
|
|
},
|
|
)
|
|
.unwrap();
|
|
let after = prepare_command(&f.project, &self_loop.command)
|
|
.unwrap()
|
|
.project;
|
|
assert!(
|
|
matches!(after.relations[&self_loop.selected].body, RelationBody::StateTransition { source, target, transition_kind: TransitionKind::External, .. } if source == f.target && target == f.target)
|
|
);
|
|
assert!(
|
|
projection::project_view(&after, diagram)
|
|
.edges
|
|
.iter()
|
|
.any(|edge| edge.id == self_loop.selected)
|
|
);
|
|
assert!(!can_connect(&f.project, diagram, f.target, f.initial));
|
|
assert!(
|
|
plan(
|
|
&f.project,
|
|
c,
|
|
&Event::Connect {
|
|
context: c,
|
|
source: f.target,
|
|
target: f.initial
|
|
}
|
|
)
|
|
.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!(!can_start(&f.project, diagram, f.leaf));
|
|
assert!(
|
|
plan(
|
|
&f.project,
|
|
c,
|
|
&Event::Connect {
|
|
context: c,
|
|
source: f.leaf,
|
|
target: f.target
|
|
}
|
|
)
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reconnect_preserves_the_complete_record_and_replaces_cardinality_in_place() {
|
|
let mut f = fixture();
|
|
let diagram = create(&mut f);
|
|
let c = context(diagram);
|
|
let trigger = add(&mut f.project, Kind::Event, "Mode requested", None);
|
|
let edge = f.project.relate(
|
|
RelationKind::StateTransition,
|
|
"Named transition",
|
|
f.composite,
|
|
f.target,
|
|
);
|
|
let r = f.project.relations.get_mut(&edge).unwrap();
|
|
r.description = "Reviewed behavior rationale".into();
|
|
if let RelationBody::StateTransition {
|
|
triggers,
|
|
trigger: notes,
|
|
guard,
|
|
effects,
|
|
effect,
|
|
..
|
|
} = &mut r.body
|
|
{
|
|
triggers.push(trigger);
|
|
*notes = "request notes".into();
|
|
*guard = "ready".into();
|
|
effects.push(f.function);
|
|
*effect = "retain this effect prose".into();
|
|
}
|
|
let artifact = Id::new_v4();
|
|
f.project.artifact_links.insert(
|
|
artifact,
|
|
ArtifactLink {
|
|
id: artifact,
|
|
alias: "transition_review".into(),
|
|
name: "Review".into(),
|
|
description: String::new(),
|
|
owner: edge,
|
|
relation: ArtifactRelation::References,
|
|
uri: "https://example.com/review".into(),
|
|
revision: Some("2".into()),
|
|
fragment: None,
|
|
},
|
|
);
|
|
let original = f.project.relations[&edge].clone();
|
|
let reconnected = plan(
|
|
&f.project,
|
|
c,
|
|
&Event::Reconnect {
|
|
context: c,
|
|
relation: edge,
|
|
endpoint: Endpoint::Target,
|
|
vertex: f.composite,
|
|
},
|
|
)
|
|
.unwrap();
|
|
let after = prepare_command(&f.project, &reconnected.command)
|
|
.unwrap()
|
|
.project;
|
|
let mut expected = original.clone();
|
|
if let RelationBody::StateTransition { target, .. } = &mut expected.body {
|
|
*target = f.composite;
|
|
}
|
|
assert_eq!(after.relations[&edge], expected);
|
|
assert_eq!(after.artifact_links, f.project.artifact_links);
|
|
assert_eq!(f.project.relations[&edge], original);
|
|
let initial_edge = f.project.relate(
|
|
RelationKind::StateTransition,
|
|
"Initial choice",
|
|
f.initial,
|
|
f.composite,
|
|
);
|
|
assert!(can_reconnect(
|
|
&f.project,
|
|
diagram,
|
|
initial_edge,
|
|
Endpoint::Target,
|
|
f.target
|
|
));
|
|
let replacement = plan(
|
|
&f.project,
|
|
c,
|
|
&Event::Reconnect {
|
|
context: c,
|
|
relation: initial_edge,
|
|
endpoint: Endpoint::Target,
|
|
vertex: f.target,
|
|
},
|
|
)
|
|
.unwrap();
|
|
let after = prepare_command(&f.project, &replacement.command)
|
|
.unwrap()
|
|
.project;
|
|
assert_eq!(after.relations[&initial_edge].target(), f.target);
|
|
assert_eq!(after.relations.len(), f.project.relations.len());
|
|
assert!(!can_reconnect(
|
|
&f.project,
|
|
diagram,
|
|
initial_edge,
|
|
Endpoint::Target,
|
|
f.initial
|
|
));
|
|
let internal = f.project.relate(
|
|
RelationKind::StateTransition,
|
|
"Internal work",
|
|
f.target,
|
|
f.target,
|
|
);
|
|
if let RelationBody::StateTransition {
|
|
transition_kind, ..
|
|
} = &mut f.project.relations.get_mut(&internal).unwrap().body
|
|
{
|
|
*transition_kind = TransitionKind::Internal;
|
|
}
|
|
assert!(
|
|
!can_reconnect(&f.project, diagram, internal, Endpoint::Target, f.composite),
|
|
"Reconnect does not convert Internal to External or rewrite its other endpoint"
|
|
);
|
|
}
|
|
}
|