Files
ARCADIA-Studio/crates/oa-desktop/src/behavior/state_edit.rs
T

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[&current]
.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"
);
}
}