731 lines
26 KiB
Rust
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);
|
|
}
|
|
}
|