From af31c8a11d810c63cf75fdc8340e3f19c237b6a7 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 02:40:26 +0300 Subject: [PATCH] Add atomic planners for direct state diagram editing --- crates/oa-desktop/src/behavior/state_edit.rs | 1081 ++++++++++++++++++ 1 file changed, 1081 insertions(+) create mode 100644 crates/oa-desktop/src/behavior/state_edit.rs diff --git a/crates/oa-desktop/src/behavior/state_edit.rs b/crates/oa-desktop/src/behavior/state_edit.rs new file mode 100644 index 00000000..df7919b4 --- /dev/null +++ b/crates/oa-desktop/src/behavior/state_edit.rs @@ -0,0 +1,1081 @@ +//! 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 }, +} + +#[derive(Debug, Clone)] +pub enum Event { + Choose(Tool), + CreateView(Id), + Select(Context, Id), + Pan(Context, iced::Vector), + Zoom(Context, f32), + Viewport(Context, iced::Size), + Fit(Context), + EditTransition(Context, Id), + 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, + }, +} + +impl Event { + pub fn context(&self) -> Option { + match self { + Self::Choose(_) | Self::CreateView(_) => None, + Self::Select(c, _) + | Self::Pan(c, _) + | Self::Zoom(c, _) + | Self::Viewport(c, _) + | Self::Fit(c) + | Self::EditTransition(c, _) + | Self::CreateAt { context: c, .. } + | Self::Place { context: c, .. } + | Self::Move { context: c, .. } + | Self::Connect { context: c, .. } + | Self::Reconnect { 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, +} + +fn error(message: impl Into) -> ModelError { + ModelError(message.into()) +} + +fn machine(project: &Project, diagram: Id) -> Result { + 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 { + 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 { + 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 { + 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() +} + +fn checked(project: &Project, plan: Plan) -> Result { + 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 { + 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(), + routes: vec![], + }, + ); + 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, +) -> Result { + 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 { + 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, + ), + _ => { + 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 { + 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 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, + 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" + ); + } +}