//! State authoring through the same validated commands as source and REST. use super::*; use iced::widget::{checkbox, column, text_input}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tool { Vertex, Region, Transition, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TransitionChoice(pub TransitionKind); impl std::fmt::Display for TransitionChoice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self.0 { TransitionKind::External => "External", TransitionKind::Local => "Local", TransitionKind::Internal => "Internal", }) } } #[derive(Debug, Clone)] pub enum Event { Start(Id, Tool, Id), Edit(Id), Cancel, Submit, Name(String), Parent(Choice), Vertex(Kind), Source(Choice), Target(Choice), Kind(TransitionChoice), Trigger(Choice), Effect(Choice), RemoveTrigger(Id), RemoveEffect(Id), TriggerNotes(String), Guard(String), EffectNotes(String), Timed(bool), TimeKind(String), Expression(String), } #[derive(Default)] pub struct State { draft: Option, } struct Draft { machine: Id, tool: Tool, original: Option, name: String, parent: Option, vertex: Kind, source: Option, target: Option, kind: TransitionKind, triggers: Vec, effects: Vec, trigger: String, guard: String, effect: String, timed: bool, time_kind: TimeEventKind, expression: String, } impl Draft { fn new(machine: Id, tool: Tool) -> Self { Self { machine, tool, original: None, name: String::new(), parent: None, vertex: Kind::State, source: None, target: None, kind: TransitionKind::External, triggers: vec![], effects: vec![], trigger: String::new(), guard: String::new(), effect: String::new(), timed: false, time_kind: TimeEventKind::After, expression: String::new(), } } fn command(&self, p: &Project) -> Result { let layer = p .elements .get(&self.machine) .filter(|e| e.kind() == Kind::StateMachine) .ok_or("Choose a state machine.")? .layer(); if self.tool != Tool::Transition { let kind = if self.tool == Tool::Region { Kind::Region } else { self.vertex }; let parent = self .parent .ok_or("Choose the owning region or composite state.")?; if parent != self.machine && !in_machine(p, parent, self.machine) { return Err("Choose an owner in this machine.".into()); } let name = if self.name.trim().is_empty() { kind.label() } else { &self.name }; let element = p .make_element(kind, layer, name, Some(parent)) .ok_or("This owner cannot contain the selected kind.")?; return Ok(Command::CreateElement(element)); } let source = self.source.ok_or("Choose the source state.")?; let target = self.target.ok_or("Choose the destination state.")?; if !in_machine(p, source, self.machine) || !in_machine(p, target, self.machine) { return Err("Both endpoints belong to this machine.".into()); } let mut commands = vec![]; let mut triggers = self.triggers.clone(); if self.timed { if self.expression.trim().is_empty() { return Err("Enter a time expression, such as 30 s.".into()); } let mut event = p .make_element( Kind::Event, layer, &format!("{:?} {}", self.time_kind, self.expression), None, ) .ok_or("Cannot create event.")?; *event.shared_mut().unwrap() = SharedBody::Event { body: EventBody::Time { kind: self.time_kind, expression: self.expression.clone(), }, }; triggers.push(event.id); commands.push(Command::CreateElement(event)); } let name = if self.name.trim().is_empty() { format!("{} → {}", p.named(source), p.named(target)) } else { self.name.clone() }; let mut relation = if let Some(id) = self.original { p.relations .get(&id) .cloned() .ok_or("The transition no longer exists.")? } else { let id = Id::new_v4(); Relation { id, alias: p.fresh_alias(&name, "transition", id), name: name.clone(), description: String::new(), body: RelationBody::new(RelationKind::StateTransition, source, target), } }; relation.name = name; relation.body = RelationBody::StateTransition { source, target, transition_kind: self.kind, trigger: self.trigger.clone(), guard: self.guard.clone(), effect: self.effect.clone(), triggers, effects: self.effects.clone(), }; commands.push(if let Some(id) = self.original { Command::EditRelation(id, relation) } else { Command::CreateRelation(relation) }); Ok(Command::Batch(commands)) } } impl State { pub(super) fn pending_owner(&self) -> Option { self.draft.as_ref().map(|d| d.machine) } pub(super) fn prepare_pending(&self, p: &Project) -> Result { let command = self .draft .as_ref() .ok_or("Choose a state modeling tool.")? .command(p)?; execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; Ok(command) } pub(super) fn acknowledge_commit(&mut self) { self.draft = None; } pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { if self.draft.is_some() && matches!(&event, Event::Start(..) | Event::Edit(..)) { return Err(PENDING_DRAFT.into()); } match event { Event::Start(machine, tool, selected) => { let mut d = Draft::new(machine, tool); let selected = p .elements .get(&selected) .filter(|e| in_machine(p, e.id, machine)); d.parent = if tool == Tool::Region { selected .filter(|e| e.kind().is_stable_state()) .map(|e| e.id) .or(Some(machine)) } else { selected .and_then(|e| validation::ancestor_of_kind(p, e.id, Kind::Region)) .or_else(|| { p.children(machine) .find(|e| e.kind() == Kind::Region) .map(|e| e.id) }) }; d.source = selected.filter(|e| e.kind().is_state()).map(|e| e.id); if d.source .is_some_and(|id| p.elements[&id].kind() == Kind::EntryPoint) { d.kind = TransitionKind::Local; } self.draft = Some(d); } Event::Edit(id) => { let r = p.relations.get(&id).ok_or("Select a transition.")?; let machine = validation::ancestor_of_kind(p, r.source(), Kind::StateMachine) .ok_or("Choose a state-machine transition.")?; let RelationBody::StateTransition { source, target, transition_kind, triggers, effects, trigger, guard, effect, } = &r.body else { return Err("Select a state transition.".into()); }; let mut d = Draft::new(machine, Tool::Transition); d.original = Some(id); d.name = r.name.clone(); d.source = Some(*source); d.target = Some(*target); d.kind = *transition_kind; d.triggers = triggers.clone(); d.effects = effects.clone(); d.trigger = trigger.clone(); d.guard = guard.clone(); d.effect = effect.clone(); self.draft = Some(d); } Event::Cancel => self.acknowledge_commit(), Event::Submit => return self.prepare_pending(p).map(Some), event => { if let Some(d) = &mut self.draft { match event { Event::Name(s) => d.name = s, Event::Parent(c) => d.parent = Some(c.id), Event::Vertex(k) => d.vertex = k, Event::Source(c) => { d.source = Some(c.id); d.target = None; d.kind = if p.elements[&c.id].kind() == Kind::EntryPoint { TransitionKind::Local } else { TransitionKind::External }; } Event::Target(c) => d.target = Some(c.id), Event::Kind(k) => { d.kind = k.0; if k.0 == TransitionKind::Internal { d.target = d.source; } } Event::Trigger(c) => { if !d.triggers.contains(&c.id) { d.triggers.push(c.id); } } Event::Effect(c) => { if !d.effects.contains(&c.id) { d.effects.push(c.id); } } Event::RemoveTrigger(id) => d.triggers.retain(|v| *v != id), Event::RemoveEffect(id) => d.effects.retain(|v| *v != id), Event::TriggerNotes(s) => d.trigger = s, Event::Guard(s) => d.guard = s, Event::EffectNotes(s) => d.effect = s, Event::Timed(b) => d.timed = b, Event::Expression(s) => d.expression = s, Event::TimeKind(s) => { d.time_kind = if s == "At" { TimeEventKind::At } else { TimeEventKind::After } } _ => {} } } } } Ok(None) } } fn event(e: Event) -> Message { Message::Behavior(super::Event::States(e)) } fn choice(p: &Project, id: Id) -> Choice { Choice { id, label: format!( "{} · {}", p.named(id), p.elements .get(&id) .map(|e| e.kind().to_string()) .unwrap_or_default() ), } } fn picker<'a>( choices: Vec, selected: Option, placeholder: &str, change: fn(Choice) -> Event, ) -> Element<'a, Message> { let current = choices.iter().find(|c| Some(c.id) == selected).cloned(); pick_list(choices, current, move |c| event(change(c))) .placeholder(placeholder) .padding(9) .text_size(12) .width(Fill) .into() } pub(super) fn form(app: &App, machine: Id) -> Option> { let p = app.project(); let d = app .behavior .states .draft .as_ref() .filter(|d| d.machine == machine)?; let mut form = column![ text(if d.original.is_some() { "Edit transition" } else { match d.tool { Tool::Vertex => "Add state or pseudostate", Tool::Region => "Add region", Tool::Transition => "Connect states", } }) .size(17), text_input("Engineering name (optional)", &d.name) .on_input(|s| event(Event::Name(s))) .padding(9) .size(12), ] .spacing(9); if d.tool != Tool::Transition { if d.tool == Tool::Vertex { form = form.push( pick_list( Kind::ALL .into_iter() .filter(|k| k.is_state()) .collect::>(), Some(d.vertex), |k| event(Event::Vertex(k)), ) .padding(9) .text_size(12) .width(Fill), ); } let parents = p .elements .values() .filter(|e| { in_machine(p, e.id, machine) && if d.tool == Tool::Vertex { e.kind() == Kind::Region } else { e.kind() == Kind::StateMachine || e.kind().is_stable_state() } }) .map(|e| choice(p, e.id)) .collect(); form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent)); form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." })); } else { let mut context = p.clone(); if let Some(id) = d.original { context.relations.remove(&id); } let vertices: Vec<_> = p .elements .values() .filter(|e| e.kind().is_state() && in_machine(p, e.id, machine)) .map(|e| choice(p, e.id)) .collect(); let targets = vertices .iter() .filter(|c| { d.source.is_some_and(|source| { if d.kind == TransitionKind::Internal { return c.id == source && p.elements[&source].kind().is_stable_state(); } if d.kind == TransitionKind::Local && p.elements[&source].kind().is_stable_state() { return c.id != source && in_machine(p, c.id, source); } validation::can_connect(&context, RelationKind::StateTransition, source, c.id) }) }) .cloned() .collect(); form = form .push(picker(vertices, d.source, "From state…", Event::Source)) .push(picker(targets, d.target, "To state…", Event::Target)) .push( pick_list( [ TransitionChoice(TransitionKind::External), TransitionChoice(TransitionKind::Local), TransitionChoice(TransitionKind::Internal), ], Some(TransitionChoice(d.kind)), |k| event(Event::Kind(k)), ) .padding(9) .text_size(12) .width(Fill), ); let layer = p.elements[&machine].layer(); let triggers = p .elements .values() .filter(|e| { e.layer() == layer && (e.kind() == Kind::Event || e.kind().is_work()) && !d.triggers.contains(&e.id) }) .map(|e| choice(p, e.id)) .collect(); form = form.push(label("Trigger events / work (alternatives)")); for id in &d.triggers { form = form.push( row![ text(p.named(*id)).size(12).width(Fill), action("Remove", event(Event::RemoveTrigger(*id))) ] .spacing(5), ); } form = form .push(picker( triggers, None, "Add existing trigger…", Event::Trigger, )) .push( checkbox(d.timed) .label("Add a time event with this transition") .on_toggle(|b| event(Event::Timed(b))), ); if d.timed { form = form.push( row![ pick_list( ["After".to_string(), "At".to_string()], Some(format!("{:?}", d.time_kind)), |s| event(Event::TimeKind(s)) ) .padding(9) .text_size(12), text_input("Time expression, e.g. 30 s", &d.expression) .on_input(|s| event(Event::Expression(s))) .padding(9) .size(12), ] .spacing(6), ); } form = form .push( text_input("Trigger notes (optional)", &d.trigger) .on_input(|s| event(Event::TriggerNotes(s))) .padding(9) .size(12), ) .push( text_input("Guard condition (optional)", &d.guard) .on_input(|s| event(Event::Guard(s))) .padding(9) .size(12), ); let effects = p .elements .values() .filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id)) .map(|e| choice(p, e.id)) .collect(); for id in &d.effects { form = form.push( row![ text(p.named(*id)).size(12).width(Fill), action("Remove", event(Event::RemoveEffect(*id))) ] .spacing(5), ); } form = form .push(picker(effects, None, "Add effect work…", Event::Effect)) .push( text_input("Effect notes (optional)", &d.effect) .on_input(|s| event(Event::EffectNotes(s))) .padding(9) .size(12), ); if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() { form = form.push(label( "No event trigger: completion from a state, transient routing from a pseudostate.", )); } } let candidate = d.command(p).and_then(|command| { execute_batch(p, &[command]) .map(|_| ()) .map_err(|e| e.to_string()) }); if let Err(error) = &candidate { form = form.push(text(error.clone()).size(12).color(style::MUTED)); } form = form.push( row![ button("Apply") .padding(9) .on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))), action("Cancel", event(Event::Cancel)) ] .spacing(7), ); Some(container(form).padding(4).into()) } #[cfg(test)] mod tests { use super::*; fn fixture() -> (Project, Id, Id, Id, Id) { let mut p = Project::empty(); let owner = p .add_element(Kind::System, Layer::Sa, "System", None) .unwrap(); let machine = p .add_element( Kind::StateMachine, Layer::Sa, "Operating modes", Some(owner), ) .unwrap(); let region = p .add_element(Kind::Region, Layer::Sa, "Operation", Some(machine)) .unwrap(); let ready = p .add_element(Kind::State, Layer::Sa, "Ready", Some(region)) .unwrap(); let degraded = p .add_element(Kind::State, Layer::Sa, "Degraded", Some(region)) .unwrap(); (p, machine, region, ready, degraded) } #[test] fn timeout_and_work_trigger_commit_together_roundtrip_and_undo() { let (mut p, machine, _, ready, degraded) = fixture(); let work = p .add_element(Kind::Function, Layer::Sa, "Monitor link", None) .unwrap(); let mut state = State::default(); for e in [ Event::Start(machine, Tool::Transition, ready), Event::Target(choice(&p, degraded)), Event::Trigger(choice(&p, work)), Event::Timed(true), Event::Expression("30 s".into()), Event::Guard("link unavailable".into()), Event::Effect(choice(&p, work)), ] { state.update(&p, e).unwrap(); } let command = state.update(&p, Event::Submit).unwrap().unwrap(); let mut session = Session::new(p.clone()); session.execute(command).unwrap(); let result = session.project(); let r = result .relations .values() .find(|r| r.kind() == RelationKind::StateTransition) .unwrap(); let RelationBody::StateTransition { triggers, effects, guard, .. } = &r.body else { panic!() }; assert_eq!(triggers.len(), 2); assert!(triggers.contains(&work)); assert_eq!(effects, &[work]); assert_eq!(guard, "link unavailable"); assert!(triggers.iter().any(|id| matches!(result.elements[id].shared(), Some(SharedBody::Event { body: EventBody::Time { kind:TimeEventKind::After, expression }}) if expression == "30 s"))); assert_eq!(&language::parse(&language::format(result)).unwrap(), result); assert!(routing::state_transition_summary(result, r).contains(" OR ")); session.undo(); assert_eq!(session.project(), &p); } #[test] fn invalid_pseudo_trigger_keeps_draft_and_does_not_create_orphan_event() { let (mut p, machine, region, ready, _) = fixture(); let initial = p .add_element(Kind::Initial, Layer::Sa, "Start", Some(region)) .unwrap(); let mut state = State::default(); for e in [ Event::Start(machine, Tool::Transition, initial), Event::Target(choice(&p, ready)), Event::Timed(true), Event::Expression("30 s".into()), ] { state.update(&p, e).unwrap(); } assert!(state.update(&p, Event::Submit).is_err()); assert!(state.draft.is_some()); assert!(!p.elements.values().any(|e| e.kind() == Kind::Event)); state.update(&p, Event::Timed(false)).unwrap(); let result = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); assert_eq!(result.relations.len(), 1); } #[test] fn internal_choice_targets_self_and_edit_preserves_identity_and_description() { let (p, machine, _, ready, _) = fixture(); let mut state = State::default(); for e in [ Event::Start(machine, Tool::Transition, ready), Event::Kind(TransitionChoice(TransitionKind::Internal)), ] { state.update(&p, e).unwrap(); } let mut result = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); state.acknowledge_commit(); let r = result.relations.values_mut().next().unwrap(); r.description = "Retain configuration".into(); let (id, alias) = (r.id, r.alias.clone()); state.update(&result, Event::Edit(id)).unwrap(); state .update(&result, Event::Guard("healthy".into())) .unwrap(); let result = execute_batch( &result, &[state.update(&result, Event::Submit).unwrap().unwrap()], ) .unwrap(); assert_eq!(result.relations[&id].alias, alias); assert_eq!(result.relations[&id].description, "Retain configuration"); assert_eq!( result.relations[&id].source(), result.relations[&id].target() ); assert!(!routing::state_transition_summary(&result, &result.relations[&id]).is_empty()); } #[test] fn selected_composite_owns_new_region_and_history_uses_selected_region() { let (p, machine, _, ready, _) = fixture(); let mut state = State::default(); state .update(&p, Event::Start(machine, Tool::Region, ready)) .unwrap(); let result = execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap(); state.acknowledge_commit(); let region = result .children(ready) .find(|e| e.kind() == Kind::Region) .unwrap() .id; state .update(&result, Event::Start(machine, Tool::Vertex, region)) .unwrap(); state .update(&result, Event::Vertex(Kind::DeepHistory)) .unwrap(); let result = execute_batch( &result, &[state.update(&result, Event::Submit).unwrap().unwrap()], ) .unwrap(); assert!( result .children(region) .any(|e| e.kind() == Kind::DeepHistory) ); assert_eq!(language::parse(&language::format(&result)).unwrap(), result); } }