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

731 lines
26 KiB
Rust

//! 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<Draft>,
}
struct Draft {
machine: Id,
tool: Tool,
original: Option<Id>,
name: String,
parent: Option<Id>,
vertex: Kind,
source: Option<Id>,
target: Option<Id>,
kind: TransitionKind,
triggers: Vec<Id>,
effects: Vec<Id>,
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<Command, String> {
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<Id> {
self.draft.as_ref().map(|d| d.machine)
}
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
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<Option<Command>, 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<Choice>,
selected: Option<Id>,
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<Element<'_, Message>> {
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::<Vec<_>>(),
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);
}
}