feat(default)!: model default(...) as a real LCA-aware transition

`default(...)` is now a transition with the same semantics as any
event-driven transition. It fires immediately after the declaring
state's entries finish and its durings start, applying the standard
exit-to-LCA / enter-to-target algorithm. Three behavioral changes
follow from this unification:

* Optional. A state with children is no longer required to declare a
  `default(...)`. When omitted, the state itself is the resting
  innermost active state; substates are reachable only via explicit
  transitions. This is the "microwave Standby" pattern — Standby
  has substates like LowPower that you enter only on demand, and
  `=> Standby;` lands at Standby itself with no descent.

* Targets anywhere. The default target may be any state in the chart
  — a child (the classic descent case), a sibling, an ancestor,
  anywhere. `default(<sibling-or-ancestor>)` exits the just-entered
  state as part of the default-fire transition; the same rule fires
  whether you wrote it for descent, redirection, or up-bounce.

* Cycle-checked at compile time. The default-edge graph (out-degree
  ≤ 1 per node) is rejected if it contains any cycle, including
  `default(self)`. `state A { default(A); }` and `A → B → A` chains
  fail to compile with a pinpoint error message naming the cycle.
  This guarantees default chains terminate at runtime.

Implementation:

* `validate.rs`: drop the "must be direct child" and "must have
  children" restrictions; add `validate_default_graph` (iterative
  three-color cycle detection via chain walk).

* `ir.rs`: keep the unresolved default ident through to
  `resolve_transitions` so non-child targets resolve against the
  global state-name map (the same map transition targets use).

* `codegen.rs`: precomputed `(from, to)` paths no longer fold default
  descent into the enter slice — each path is LCA-relative
  `(exit, enter)` ending exactly at the explicit target.
  `descend_defaults` (a parent-pointer walk) is replaced by
  `follow_defaults`, which fires real transitions through
  `apply_path` and emits `TransitionFired{Internal}` /
  `TransitionComplete` brackets per link. Up-transitions don't
  trigger default-following (nothing was freshly entered, so no
  state's default fires).

* `active_durings_per_innermost_state`: predicate widened to include
  composites without a default (they can now be the innermost active
  state, so any durings they declare must be raced when current).

BREAKING CHANGES:

* Journal format. Default-fires now emit `TransitionFired` (with
  `reason: TransitionReason::Internal`) and `TransitionComplete`
  brackets around their entry sequence. `Internal` was previously
  reserved and never emitted; it is now active. Tests that
  hand-build expected journal sequences must include these brackets
  around every default-fire.

* Validation. `default(<non-child>)` and `default` on a childless
  state previously raised compile-time errors. Both are now valid.
  Code relying on these as guard rails must adapt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.7 committed 2026-04-28 20:57:01 -06:00
1 parent 2e55484080
commit 3fc0d7c57d
30 files changed
+1356 -229

No files matched your search

+47 -19
View File
@@ -66,27 +66,51 @@ The full canonical reference lives at
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md);
this section is the on-ramp.
### 1. The active path — you are in every state from root to leaf
### 1. The active path — you are in every state from root to the innermost active state
At any moment, the machine is in a **path** of states from the root down
to one innermost state. If you're in `C` inside `B` inside `A`, you're
**simultaneously** in `Root`, `A`, `B`, and `C`. The root is always
active until termination.
to one innermost active state. If you're in `C` inside `B` inside `A`,
you're **simultaneously** in `Root`, `A`, `B`, and `C`. The root is
always active until termination.
The innermost active state is usually a leaf, but it can also be a
composite that has children but no `default(...)` — see rule 2.
This is the most important rule. Every rule below is downstream of it.
If a behavior somewhere else seems weird, come back here — it's
probably the resolution.
### 2. Default-descent — composites auto-enter their default child
### 2. `default(...)` — a transition that fires immediately on entry
Every state with children declares one `default(...)`. When a composite
state is entered, its entry actions run first, **then** the default
child is entered, recursively, until a leaf.
Any state **may** declare one `default(...)`. It's a transition — same
LCA-aware algorithm as any event-triggered transition (rule 4) — that
fires **immediately** after the declaring state's entry actions finish
(and its durings start). The target may be **any** state in the chart:
a child, a sibling, an ancestor, anywhere. The compiler rejects cycles
in the default graph, so the chain is always finite.
So `Root` with `default(A)` and `A` with `default(B)` means: the
machine starts by entering `Root` → `A` → `B`, all on first step.
The classic case is a composite descending to a leaf:
```
state Root { default(A); state A { default(B); state B {} } }
```
Entering `Root` fires its default → enter `A` → fires its default →
enter `B`. The path lands at `B`.
### 3. Event bubbling — leaf first, first handler wins
But because `default` is a real transition, you can also redirect:
```
state Foyer { default(LivingRoom); } // sibling — entering Foyer
state LivingRoom {} // exits Foyer to LivingRoom
```
Entering `Foyer` fires its default `Foyer → LivingRoom`. LCA = parent of
both; Foyer's exits run, LivingRoom's entries run. Foyer was entered
and exited as part of one tick.
If a state has no `default(...)`, nothing happens after its entries —
the state itself is the resting innermost active state until something
explicitly transitions away. This is the "microwave" pattern: a
`Standby` state with sub-modes that the chart enters only on demand.
### 3. Event bubbling — innermost first, first handler wins
An event arrives. Search starts at the **innermost** active state. If
that state has a handler, it runs and the event is consumed. If not,
@@ -95,7 +119,7 @@ with no handler, the event is silently discarded.
Three corollaries:
- Leaf shadows ancestor for the same trigger.
- Innermost shadows ancestor for the same trigger.
- Multiple handlers in one state on the same trigger fire in
declaration order; the transition (if any) fires last.
- **Timers don't bubble.** A timer belongs to the state that declared
@@ -110,7 +134,9 @@ Any transition's algorithm is uniform:
The LCA itself is **not** exited.
3. Walk **down** from the LCA to target, entering each state. The LCA
is **not** re-entered.
4. If target has children, default-descend (rule 2).
4. If the target declares a `default(...)`, fire it (rule 2). Defaults
chain — each link is itself a full transition — until you reach a
state with no default. The compiler rejects cycles.
The LCA always exists because root is always an upper bound. State
names are globally unique across the chart, so a transition can target
@@ -125,8 +151,9 @@ transition algorithm do nothing. You exit the states strictly between
current and target; the target itself is **not** re-entered.
So from leaf `C` inside `B` inside `A`, an `on(Up) => A;` exits `C`
then `B`. `A`'s entry actions don't fire. `A`'s `default` is not
followed. `A`'s timers don't restart. `A`'s durings keep running.
then `B`. `A`'s entry actions don't fire. `A`'s `default` does not
fire (it only fires when `A` is freshly entered, which it wasn't).
`A`'s timers don't restart. `A`'s durings keep running.
You cannot enter a state you never left.
@@ -136,10 +163,11 @@ Direction is set by the path:
- **Entries** fire **outer-to-inner**: when entering down through a
path, the outermost state's entries fire first, then the next, then
the leaf's. Within a single state, entry actions fire in declaration
order. After a state's entries finish, its `during:` activities
start. Then default-descent (rule 2) applies recursively.
- **Exits** fire **inner-to-outer**: leaf first, then parent, then
the innermost's. Within a single state, entry actions fire in
declaration order. After a state's entries finish, its `during:`
activities start. Then default-descent (rule 2) applies recursively
if a `default` is declared.
- **Exits** fire **inner-to-outer**: innermost first, then parent, then
grandparent. Within a single state, exit actions fire in declaration
order. Before a state's exits begin, its `during:` activities are
cancelled.
+36 -20
View File
@@ -37,10 +37,16 @@ Each primitive, in roughly the order a developer first meets them.
to the target, calling entries along the way.
- **Action (without transition)** — a function call attached to a trigger.
No state change. That's it.
- **Default child** — every non-leaf state declares one default child.
Whenever a state with children is entered, the default child is entered
immediately after that state's entry actions finish — it's effectively
an automatic transition into the child.
- **Default transition** — any state **may** declare one `default(...)`.
It's a transition (same LCA-aware algorithm as rule 4 / event-driven
transitions) that fires immediately after the declaring state's entry
actions finish and its durings start. The target may be any state in
the chart — a child (the classic descent case), a sibling, an
ancestor, anywhere. Defaults chain (each link is itself a transition)
until they reach a state with no default. The compiler rejects cycles
in the default graph at compile time, so every chain terminates. When
a state has no `default`, nothing else happens after entry — the
state itself is the resting innermost active state.
- **Trigger: event vs. duration** — an event is a variant of your event
enum. A duration is a timer (`after Duration` fires once, `every
Duration` fires repeatedly). Triggers attach to transitions and to
@@ -71,7 +77,8 @@ seems weird, come back to this one — it's probably the resolution.
`current_state()` returns the innermost active state, which is usually a
leaf, but can be a composite (a state with children) if you got there via
an up-transition (see below) and haven't moved since.
an up-transition (see below) and haven't moved since, or if you
transitioned into a composite that has no `default` declared.
---
@@ -121,8 +128,10 @@ To run a transition:
itself is **not** exited.
3. Walk **down** from the LCA to the target, entering each state on the
way (run entry actions → start durings). The LCA is **not** re-entered.
4. If the target has children, follow the default-child chain down to a
leaf, entering each as you go.
4. If the target declares a `default(...)`, fire it as a recursive
transition (using the same algorithm). Defaults chain until a state
with no default is reached. Compile-time cycle check guarantees
termination.
The three flavors below are just three relationships between current and
target. The same algorithm covers all of them — they only look different
@@ -131,8 +140,8 @@ in which steps fire.
### Transitioning to a sibling (or anywhere not on your active path)
The standard case. LCA is somewhere strictly above both current and
target. Exit up to LCA, enter down to target, default-descend if the
target has children. Already covered above.
target. Exit up to LCA, enter down to target, default-descend from the
target if it declares a `default`. Already covered above.
### Transitioning to yourself
@@ -153,11 +162,12 @@ Timers declared on the target restart, because the target itself was
exited and re-entered.
If the target happens to be a composite (it has children, but no child is
active right now because you got there via an up-transition), the same
default-child rule still applies on re-entry: exit the composite, re-enter
it, then descend through its default chain. Self-transitions don't add a
new rule — they're just the case where source and target are the same
state.
active right now because you got there via an up-transition or because
the composite has no `default`), the same rule still applies on re-entry:
exit the composite, re-enter it, then descend through its default chain
if it declares one. A composite without `default` is exited, re-entered,
and that's it — no further descent. Self-transitions don't add a new
rule — they're just the case where source and target are the same state.
Personally I don't find self-transitions all that valuable, but people
talk about them all the time, so they're supported.
@@ -207,7 +217,11 @@ Putting it all together:
outermost state's entry actions fire first, then its child's, and so on
down to the innermost. Within a single state, entry actions fire in
declaration order. After entry actions of a state finish, that state's
durings start. Then the default child (if any) is entered, recursively.
durings start. Then, if the state declares a `default(...)`, the
default fires as a transition (which may exit the state we just
entered if the target lies elsewhere in the chart) and the chain
continues from there. A state without `default` is the end of the
path — control stops there.
- **Exit** runs **inner-to-outer**: when leaving a path of states, the
innermost state exits first, then its parent, and so on up. Within a
single state, exit actions fire in declaration order. Before exit
@@ -367,11 +381,13 @@ drafts. Edit freely.)_
| Scenario | What fires |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Initial machine start | Root entries → default-descend down through each child's entries → durings start at each entered state. |
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → default-descend if target has children. |
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If innermost has children, default-descend after re-entry. |
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. No default-descent. Ancestor's durings and timers continue. |
| Transition into a state with children | After the target's entries fire, default-descend through each default child's entries down to a leaf. |
| Initial machine start | Root entries → if root declares a `default(...)`, fire it as a transition; chain continues until a state with no default. Durings start at each entered state. |
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → if target declares a `default`, fire it; chain continues. |
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If the re-entered state declares a `default`, fire it. |
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. The ancestor's `default` is **not** fired (it wasn't freshly entered). Durings and timers continue. |
| Transition into a state with `default(<descendant>)` | After the target's entries fire, the default fires as a child-direction transition (no exits at this step). The chain continues from the new innermost. |
| Transition into a state with `default(<sibling/ancestor>)` | After the target's entries fire, the default fires as a transition that exits the just-entered target and lands elsewhere. The chain continues. |
| State has no `default(...)` | After its entries fire, control stops there. The state itself is the innermost active state until something explicitly transitions away. |
| Timer fires | The declaring state's handler runs (timers don't bubble). |
| Event with no handler | Silently discarded. |
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). |
+124 -68
View File
@@ -100,21 +100,27 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
/// in `transition()` with two slice lookups and two straight slice iterations.
///
/// The (from, to) lookup matches the runtime LCA call: `from` is the active
/// leaf (`self.current`), `to` is the transition target. The same three
/// transition shapes are encoded uniformly by what the precomputed paths
/// contain:
/// innermost state (`self.current`), `to` is the transition target. The
/// same three transition shapes are encoded uniformly by what the
/// precomputed paths contain:
///
/// - **Up-transition** (`to` is a strict ancestor of `from`): exit_path is
/// `from..to` (target excluded; `to` is already on the active path and
/// must not be re-entered). enter_path is empty — `transition()` sets
/// `self.current = Some(target)` after the loops.
/// - **Self-transition** (`from == to`): exit_path is `[to]`. enter_path
/// is `[to, default_child(to), default_child(default_child(to)), ...]`
/// — re-enter target plus descend defaults.
/// is `[to]` — re-enter the target.
/// - **Lateral** (LCA is a proper ancestor of both): exit_path walks
/// `from` up to but not including LCA. enter_path walks
/// LCA-side child of `to` down to `to`, then continues descending
/// `__DEFAULT_CHILD` from `to`.
/// LCA-side child of `to` down to `to`.
///
/// Default-descent is **not** folded into these paths. After `transition()`
/// applies the precomputed exit/enter, it follows the default chain by
/// firing further transitions in a loop (`follow_defaults`). This is
/// because `default(...)` may target any state in the chart — sibling,
/// ancestor, anywhere — so each step of the chain is itself a full
/// LCA-aware transition, not a simple child descent. The compile-time
/// default-graph cycle check guarantees the loop terminates.
///
/// Storage: `__EXIT_DATA` and `__ENTER_DATA` are flat `[u16]` blobs;
/// `__EXIT_RANGE[i*N + j]` / `__ENTER_RANGE[i*N + j]` give `(start, end)`
@@ -126,7 +132,6 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
let n = states.len();
let parent = |s: u16| -> Option<u16> { states[s as usize].parent };
let default_child = |s: u16| -> Option<u16> { states[s as usize].default_child };
let depth = |s: u16| -> i32 { states[s as usize].depth as i32 };
let lca = |mut a: u16, mut b: u16| -> Option<u16> {
@@ -171,21 +176,15 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
return (exit_path, Vec::new());
}
// Self-transition: external semantics — exit and re-enter target,
// then descend defaults.
// Self-transition: external semantics — exit and re-enter target.
// Default-following (if any) happens in the runtime chain loop.
if from == to {
let exit_path = vec![to];
let mut enter_path = vec![to];
let mut cur = default_child(to);
while let Some(c) = cur {
enter_path.push(c);
cur = default_child(c);
}
return (exit_path, enter_path);
return (vec![to], vec![to]);
}
// Lateral: exit `from` up to (not including) LCA; enter from
// LCA-child on `to`'s side down to `to`, then descend defaults.
// LCA-child on `to`'s side down to `to`. Default-following
// happens in the runtime chain loop.
let lca_id = lca(from, to);
let mut exit_path = Vec::new();
@@ -213,13 +212,7 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
None => break,
}
}
let mut enter_path: Vec<u16> = rev.into_iter().rev().collect();
let mut cur = default_child(to);
while let Some(c) = cur {
enter_path.push(c);
cur = default_child(c);
}
let enter_path: Vec<u16> = rev.into_iter().rev().collect();
(exit_path, enter_path)
};
@@ -260,8 +253,8 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
(exit_data, enter_data, exit_range, enter_range)
}
/// Static precompute of `do_terminate()`'s exit walk: for each leaf state,
/// the chain `[current, parent(current), ..., __Root]` (root inclusive,
/// Static precompute of `do_terminate()`'s exit walk: for each state, the
/// chain `[current, parent(current), ..., __Root]` (root inclusive,
/// since `__Root` may carry root-level `entry:`/`exit:` actions declared
/// at the chart top level — see the `det_initial` test for that pattern).
///
@@ -376,17 +369,21 @@ fn compute_event_dispatch(ir: &Ir) -> Vec<(u16, Vec<u16>, Option<u16>)> {
out
}
/// For each user-declared leaf state that has one or more `during:` activities
/// active (either declared on it, on any ancestor, or on root), return its
/// state id and the flattened list of durings in leaf-first order. Durings
/// declared on the same state follow their declaration order.
/// For each state that can be the innermost active state, return its state id
/// and the flattened list of `during:` activities active there (declared on it
/// or on any ancestor) in leaf-first order. Durings declared on the same state
/// follow their declaration order.
///
/// Leaf states with no active durings are omitted — the run loop uses its
/// default (channel+timer) path for those.
fn active_durings_per_leaf(ir: &Ir) -> Vec<(u16, Vec<During>)> {
/// A state can be innermost iff it has no children, OR it has children but no
/// `default` child (so default-descent stops at it). Composites with a default
/// child are always descended past at runtime, so they're skipped here.
///
/// Innermost-able states with no active durings are omitted — the run loop
/// uses its default (channel+timer) path for those.
fn active_durings_per_innermost_state(ir: &Ir) -> Vec<(u16, Vec<During>)> {
let mut out = Vec::new();
for s in &ir.states {
if !s.children.is_empty() {
if !s.children.is_empty() && s.default_child.is_some() {
continue;
}
if s.parent.is_none() {
@@ -425,14 +422,14 @@ fn emit_during_bindings(chain: &[During]) -> (Vec<Ident>, TokenStream) {
(idents, quote! { #(#stmts)* })
}
/// Emit the per-leaf match arms for the tokio run loop. Each arm constructs
/// the active during: futures (split-borrowing context fields), then races
/// them against the event channel and the next-timer sleep via
/// `tokio::select!`. The default arm — used for leaves with no durings — is
/// emitted by the caller.
/// Emit the per-state match arms for the tokio run loop, keyed on the
/// innermost active state id. Each arm constructs the active during: futures
/// (split-borrowing context fields), then races them against the event channel
/// and the next-timer sleep via `tokio::select!`. The default arm — used for
/// states with no active durings — is emitted by the caller.
fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
let mut arms = Vec::new();
for (sid, chain) in active_durings_per_leaf(ir) {
for (sid, chain) in active_durings_per_innermost_state(ir) {
let (binds, stmts) = emit_during_bindings(&chain);
let select_arms = binds.iter().map(|b| {
quote! { ev = #b => __HsmcRace::Event(ev), }
@@ -462,7 +459,7 @@ fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
/// (no `events:` declared, no channel). The default arm races only the timer.
fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
let mut arms = Vec::new();
for (sid, chain) in active_durings_per_leaf(ir) {
for (sid, chain) in active_durings_per_innermost_state(ir) {
let (binds, stmts) = emit_during_bindings(&chain);
let select_arms = binds.iter().map(|b| {
quote! { ev = #b => __HsmcRace::Event(ev), }
@@ -639,7 +636,7 @@ fn embassy_select_for_arity(arity: usize) -> (Ident, Vec<Ident>) {
/// compiles unchanged.
fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
let mut arms = Vec::new();
for (sid, chain) in active_durings_per_leaf(ir) {
for (sid, chain) in active_durings_per_innermost_state(ir) {
let n = chain.len();
if n + 2 > 6 {
let msg = format!(
@@ -703,7 +700,7 @@ fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
/// durings on a timer-only path.
fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
let mut arms = Vec::new();
for (sid, chain) in active_durings_per_leaf(ir) {
for (sid, chain) in active_durings_per_innermost_state(ir) {
let n = chain.len();
if n == 0 {
continue; // falls through to default (just timer)
@@ -1104,8 +1101,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
__timer_reason_str(__t_state, __t_trigger)
);
};
#[allow(dead_code)]
let _observe_transition_fired_internal = quote! {
let observe_transition_fired_internal = quote! {
::hsmc::__chart_observe!(
TransitionFired,
&mut self.__journal,
@@ -1890,7 +1886,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
// run() bodies to decide whether to exit when no timers are pending: if
// the current state has an active during, the run loop should keep
// awaiting rather than break (the during might yet produce an event).
let states_with_active_durings: Vec<u16> = active_durings_per_leaf(ir)
let states_with_active_durings: Vec<u16> = active_durings_per_innermost_state(ir)
.into_iter()
.map(|(sid, _)| sid)
.collect();
@@ -2595,7 +2591,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
/// Returns `true` once the machine has processed its `terminate` event (§4.4).
pub fn is_terminated(&self) -> bool { self.terminated }
/// Return the current leaf state. Panics if called before the
/// Return the innermost active state. Panics if called before the
/// first `step()` (§4.4).
pub fn current_state(&self) -> #state_enum_name {
__state_id_to_public(self.current.expect("machine not started; call step() first"))
@@ -2950,12 +2946,18 @@ pub fn generate(ir: &Ir) -> TokenStream {
// An empty enter slice (`m == e`) means up-transition: target
// is already on the active path, must not be re-entered, and
// `self.current` is set to `target` after the exit loop. A
// non-empty enter slice ends at the deepest default-descended
// leaf; `enter_state_no_descent` sets `self.current` as it
// goes, so no fixup is needed on that path.
// non-empty enter slice ends at the target itself —
// `enter_state_no_descent` sets `self.current` as it goes.
// Default-following runs after the path apply via
// `follow_defaults`, which fires further transitions for any
// chained `default(...)` declarations on the just-entered
// state. The compile-time default-graph cycle check
// guarantees the chain terminates.
// Returns true if any state was newly entered (i.e., the
// enter_path was non-empty). False on an up-transition, where
// the target was already on the active path.
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn transition(&mut self, _source_matched_state: u16, target: u16) {
let from = self.current.unwrap_or(target);
fn apply_path(&mut self, from: u16, target: u16) -> bool {
let idx = (from as usize) * #n_states + (target as usize);
let (s, m, e) = __PATH_RANGE[idx];
let exit_path = &__PATH_DATA[s as usize..m as usize];
@@ -2965,15 +2967,18 @@ pub fn generate(ir: &Ir) -> TokenStream {
}
for &sid in enter_path {
self.enter_state_no_descent(sid);
if self.terminated { return !enter_path.is_empty(); }
}
if enter_path.is_empty() {
self.current = Some(target);
false
} else {
true
}
}
#[cfg(any(feature = "tokio", feature = "embassy"))]
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
let from = self.current.unwrap_or(target);
async fn apply_path(&mut self, from: u16, target: u16) -> bool {
let idx = (from as usize) * #n_states + (target as usize);
let (s, m, e) = __PATH_RANGE[idx];
let exit_path = &__PATH_DATA[s as usize..m as usize];
@@ -2983,22 +2988,48 @@ pub fn generate(ir: &Ir) -> TokenStream {
}
for &sid in enter_path {
self.enter_state_no_descent(sid).await;
if self.terminated { return !enter_path.is_empty(); }
}
if enter_path.is_empty() {
self.current = Some(target);
false
} else {
true
}
}
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn transition(&mut self, _source_matched_state: u16, target: u16) {
let from = self.current.unwrap_or(target);
let entered = self.apply_path(from, target);
if self.terminated { return; }
if entered {
self.follow_defaults();
}
}
#[cfg(any(feature = "tokio", feature = "embassy"))]
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
let from = self.current.unwrap_or(target);
let entered = self.apply_path(from, target).await;
if self.terminated { return; }
if entered {
self.follow_defaults().await;
}
}
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn enter_path(&mut self, root: u16) {
self.enter_state_no_descent(root);
self.descend_defaults(root);
if self.terminated { return; }
self.follow_defaults();
}
#[cfg(any(feature = "tokio", feature = "embassy"))]
async fn enter_path(&mut self, root: u16) {
self.enter_state_no_descent(root).await;
self.descend_defaults(root).await;
if self.terminated { return; }
self.follow_defaults().await;
}
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
@@ -3041,21 +3072,46 @@ pub fn generate(ir: &Ir) -> TokenStream {
#observe_entered
}
// After a state's entries finish (initial entry or any
// transition), follow its `default(...)` chain by firing
// further transitions. Each step is a full LCA-aware
// transition because the default target may be a sibling,
// ancestor, or anywhere else — not just a direct child. The
// compile-time default-graph cycle check guarantees the loop
// terminates.
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn descend_defaults(&mut self, sid: u16) {
let mut cur = sid;
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
self.enter_state_no_descent(child);
cur = child;
fn follow_defaults(&mut self) {
while let Some(cur) = self.current {
let target = match __DEFAULT_CHILD[cur as usize] {
Some(t) => t,
None => return,
};
#observe_transition_fired_internal
let __t_complete_from = self.current;
let entered = self.apply_path(cur, target);
#observe_transition_complete
if self.terminated { return; }
// If the default fired an up-transition (no new state
// entered), don't keep chaining — the ancestor we
// landed on was already on the active path; its
// default was not "freshly entered" and must not fire.
if !entered { return; }
}
}
#[cfg(any(feature = "tokio", feature = "embassy"))]
async fn descend_defaults(&mut self, sid: u16) {
let mut cur = sid;
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
self.enter_state_no_descent(child).await;
cur = child;
async fn follow_defaults(&mut self) {
while let Some(cur) = self.current {
let target = match __DEFAULT_CHILD[cur as usize] {
Some(t) => t,
None => return,
};
#observe_transition_fired_internal
let __t_complete_from = self.current;
let entered = self.apply_path(cur, target).await;
#observe_transition_complete
if self.terminated { return; }
if !entered { return; }
}
}
+35 -6
View File
@@ -79,7 +79,17 @@ pub struct StateIr {
pub name: Ident,
pub parent: Option<u16>,
pub depth: u8,
/// Target of this state's `default(...)` declaration, if any. Resolved
/// in `resolve_transitions`. The target may be any state in the chart
/// (not just a direct child) — `default(...)` is a transition that
/// fires immediately after the declaring state's entries, using the
/// same LCA-aware algorithm as event-driven transitions. The
/// default-graph cycle check in `validate.rs` ensures the chain
/// terminates.
pub default_child: Option<u16>,
/// Original ident from the parse tree, kept until resolution so we can
/// emit good error spans on unknown targets / cycles.
pub default_target_ident: Option<(Ident, Span)>,
pub entries: Vec<u16>, // action ids
pub exits: Vec<u16>,
pub handlers: Vec<HandlerIr>,
@@ -192,6 +202,7 @@ fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: S
parent,
depth,
default_child: None,
default_target_ident: None,
entries: Vec::new(),
exits: Vec::new(),
handlers: Vec::new(),
@@ -451,12 +462,11 @@ fn lower_body(
}
ir.states[state_id as usize].children = child_ids.clone();
if let Some((dc_ident, _)) = &body.default_child {
let resolved = child_ids
.iter()
.find(|&&cid| ir.states[cid as usize].name == *dc_ident)
.copied();
ir.states[state_id as usize].default_child = resolved;
// Stash the unresolved default target. The actual id is filled in by
// `resolve_transitions`, which has the global state-name → id map and
// can resolve targets that aren't direct children.
if let Some((dc_ident, dc_span)) = &body.default_child {
ir.states[state_id as usize].default_target_ident = Some((dc_ident.clone(), *dc_span));
}
Ok(())
@@ -547,5 +557,24 @@ pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
}
}
}
// Resolve default-transition targets against the same global map.
for i in 0..n {
if let Some((ident, _span)) = ir.states[i].default_target_ident.clone() {
let tname = ident.to_string();
match name_map.get(&tname).copied() {
Some(id) => {
ir.states[i].default_child = Some(id);
}
None => {
return Err(syn::Error::new(
ident.span(),
format!("unknown state `{}` in `default(...)`", ident),
));
}
}
}
}
Ok(())
}
+76 -34
View File
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
use crate::parse::{StateBody, StatechartInput};
pub fn validate(ir: &Ir) -> syn::Result<()> {
validate_default_graph(ir)?;
// Duplicate state names
let mut seen = HashMap::new();
for s in &ir.states {
@@ -94,16 +95,11 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
}
}
// Children require default; default must be a direct child; no default allowed if no children.
if !s.children.is_empty() && s.default_child.is_none() {
return Err(syn::Error::new(
s.span,
format!(
"state `{}` has children but no `default` declaration",
s.name
),
));
}
// `default` is optional: a composite without a `default(...)` is a
// valid resting state — transitions targeting it land on the composite
// itself, with no descent. Substates of such a composite are reachable
// only via explicit transitions. (`default` must still be a direct
// child when declared; that's checked in `validate_parse_tree`.)
// duplicate transitions per trigger in this state, AND transition
// target (if present) must be declared after every action handler
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
Ok(())
}
/// Walk the default-edge graph and reject cycles. Each state has at most one
/// outgoing default edge, so the graph has out-degree ≤ 1 and any cycle is a
/// simple chain that loops. Detection: 3-color DFS via an iterative chain walk.
///
/// Cycles are rejected at compile time because at runtime, a default chain
/// fires as a sequence of immediate transitions on entry — a cycle would loop
/// forever, never returning control to the dispatcher.
fn validate_default_graph(ir: &Ir) -> syn::Result<()> {
let n = ir.states.len();
// 0 = white (unvisited), 1 = gray (on current chain), 2 = black (done).
let mut color = vec![0u8; n];
for start in 0..n {
if color[start] != 0 {
continue;
}
let mut chain: Vec<usize> = Vec::new();
let mut cur = start;
loop {
if color[cur] == 2 {
// Joined a previously-cleared subgraph. Chain so far is fine.
break;
}
if color[cur] == 1 {
// Back-edge into the current chain — cycle.
let cycle_start = chain
.iter()
.position(|&x| x == cur)
.expect("gray node must be on current chain");
let cycle: Vec<String> = chain[cycle_start..]
.iter()
.map(|&id| ir.states[id].name.to_string())
.collect();
let pretty = format!("{} -> {}", cycle.join(" -> "), cycle[0]);
let err_span = ir.states[chain[cycle_start]]
.default_target_ident
.as_ref()
.map(|(_, s)| *s)
.unwrap_or(ir.states[chain[cycle_start]].span);
return Err(syn::Error::new(
err_span,
format!(
"default-transition cycle detected: {} \
(entering any state in this cycle would loop forever \
before reaching user code)",
pretty
),
));
}
color[cur] = 1;
chain.push(cur);
match ir.states[cur].default_child {
Some(next) => cur = next as usize,
None => break,
}
}
for &id in &chain {
color[id] = 2;
}
}
Ok(())
}
/// Additional checks that require the original parse tree (for counting duplicate
/// `default`/`terminate` with their spans, and the "default in childless state" /
/// "default not a direct child" / "terminate in non-root" errors).
/// `default`/`terminate` with their spans, and the "terminate in non-root" error).
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
let root_name = input.name.to_string();
validate_body(&input.body, true, &root_name)
@@ -217,28 +274,13 @@ fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Resu
));
}
}
if body.default_child.is_some() && body.children.is_empty() {
return Err(syn::Error::new(
body.default_span.unwrap(),
format!(
"`default` in state `{}` which has no child states",
state_name
),
));
}
if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) {
// default must be a direct child
let is_child = body.children.iter().any(|c| c.name == *dc);
if !is_child {
return Err(syn::Error::new(
*dspan,
format!(
"`default({})` in state `{}`: `{}` is not a direct child of `{}`",
dc, state_name, dc, state_name
),
));
}
}
// default(...) is a transition that fires immediately after the
// declaring state's entries finish. It may be declared on any state
// (composite or leaf) and may target any state in the chart. A leaf
// with `default(T)` just transitions to T on every entry. The
// default-graph cycle check in `validate_default_graph` (run from the
// top-level `validate_parse_tree` over the lowered IR) ensures we
// cannot construct an infinite chain at startup.
for c in &body.children {
let cname = c.name.to_string();
validate_body(&c.body, false, &cname)?;
@@ -1,24 +0,0 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Parent);
state Parent {
state ChildA { entry: e; }
state ChildB { entry: e; }
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -1,5 +0,0 @@
error: state `Parent` has children but no `default` declaration
--> tests/ui/t9_2_missing_default.rs:13:11
|
13 | state Parent {
| ^^^^^^
@@ -1,25 +0,0 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Parent);
state Parent {
default(Cousin);
state Child { entry: e; }
}
state Cousin { entry: e; }
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -1,5 +0,0 @@
error: `default(Cousin)` in state `Parent`: `Cousin` is not a direct child of `Parent`
--> tests/ui/t9_4_default_non_child.rs:14:9
|
14 | default(Cousin);
| ^^^^^^^
@@ -1,3 +1,6 @@
// `default(self)` is a 1-cycle: entering A would fire its default
// transition to A, which would fire its default again, ad infinitum.
// Compile-time error.
use hsmc::statechart;
#[derive(Debug, Clone)]
@@ -9,9 +12,9 @@ statechart! {
M {
context: Ctx;
events: Ev;
default(Leaf);
state Leaf {
default(Ghost);
default(A);
state A {
default(A);
entry: e;
}
}
@@ -0,0 +1,5 @@
error: default-transition cycle detected: A -> A (entering any state in this cycle would loop forever before reaching user code)
--> tests/ui/t9_4_default_self_cycle.rs:17:9
|
17 | default(A);
| ^^^^^^^
@@ -1,5 +0,0 @@
error: `default` in state `Leaf` which has no child states
--> tests/ui/t9_5_default_childless.rs:14:9
|
14 | default(Ghost);
| ^^^^^^^
@@ -0,0 +1,30 @@
// A → B → A is a default cycle: entering either state would loop
// forever before reaching user code. Compile-time error.
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(A);
state A {
default(B);
entry: e;
}
state B {
default(A);
entry: e;
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,5 @@
error: default-transition cycle detected: A -> B -> A (entering any state in this cycle would loop forever before reaching user code)
--> tests/ui/t9_5_default_two_cycle.rs:16:9
|
16 | default(B);
| ^^^^^^^
@@ -0,0 +1,50 @@
//! Compile-pass: a composite state may omit `default(...)`.
//!
//! When a composite has children but no `default`, transitions targeting
//! that composite land on the composite itself — the substates are reachable
//! only via explicit transitions. Microwave-style chart: `Standby` is the
//! resting state and `LowPower` is a sub-mode you enter only on demand.
#![allow(unexpected_cfgs)]
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev {
PowerOn,
PowerOff,
EnterLowPower,
LeaveLowPower,
Halt,
}
#[derive(Default)]
pub struct Ctx;
statechart! {
Microwave {
context: Ctx;
events: Ev;
default(Standby);
terminate(Halt);
state Standby {
// No `default(...)` — Standby is itself the resting state.
on(PowerOn) => On;
on(EnterLowPower) => LowPower;
state LowPower {
on(LeaveLowPower) => Standby;
}
}
state On {
on(PowerOff) => Standby;
}
}
}
impl MicrowaveActions for MicrowaveActionContext<'_> {}
fn main() {
let _ = Microwave::new(Ctx);
}
@@ -0,0 +1,43 @@
//! Compile-pass: a state's `default(...)` may target an ancestor. Entering
//! the declaring state would immediately exit back up to the ancestor (the
//! up-transition rule applies — ancestor not re-entered). The state is
//! reached only via an explicit transition; without one, control never
//! enters it. (No cycle here: Outer has no default; Inner targets Outer.)
#![allow(unexpected_cfgs)]
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { GoIn, Halt }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Outer);
terminate(Halt);
state Outer {
entry: outer_in;
on(GoIn) => Inner;
state Inner {
entry: inner_in;
// Default jumps up to Outer. Inner is reachable only via the
// explicit GoIn transition; once entered, this default fires
// and lands the chart back at Outer (no re-entry of Outer).
default(Outer);
}
}
}
}
impl MActions for MActionContext<'_> {
fn outer_in(&mut self) {}
fn inner_in(&mut self) {}
}
fn main() {
let _ = M::new(Ctx);
}
@@ -0,0 +1,38 @@
//! Compile-pass: a state's `default(...)` may target a sibling. Entering
//! the declaring state immediately fires a transition out to the sibling,
//! using the same LCA-aware algorithm as any other transition.
#![allow(unexpected_cfgs)]
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { Halt }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Foyer);
terminate(Halt);
state Foyer {
// Default redirects out of Foyer to its sibling LivingRoom.
default(LivingRoom);
entry: foyer_in;
}
state LivingRoom {
entry: living_in;
}
}
}
impl MActions for MActionContext<'_> {
fn foyer_in(&mut self) {}
fn living_in(&mut self) {}
}
fn main() {
let _ = M::new(Ctx);
}
+4 -2
View File
@@ -56,8 +56,10 @@ pub enum TransitionReason {
/// fired this transition.
Timer { state: u16, timer: u16 },
/// Driven from inside the runtime without an originating event or
/// timer (e.g. default-descent into a child after entering a
/// composite state). Reserved; not currently emitted.
/// timer. Emitted for `default(...)` firings: after a state with a
/// declared default is entered, the default fires as a transition
/// with this reason. The default target may be any state in the
/// chart — sibling, ancestor, anywhere.
Internal,
}
+136
View File
@@ -0,0 +1,136 @@
#![allow(dead_code)]
//! Deterministic-flow tests: `default(...)` targeting an ANCESTOR.
//! Inner's `default(Outer)` is a valid up-default. Inner is reachable
//! only via an explicit transition. When entered, its default fires
//! immediately, exiting Inner back to Outer (up-transition rule:
//! Outer is not re-entered).
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
GoIn,
Halt,
}
statechart! {
AncestorDefault {
context: Ctx;
events: Ev;
default(Outer);
terminate(Halt);
state Outer {
entry: outer_in;
on(GoIn) => Inner;
state Inner {
entry: inner_in;
exit: inner_out;
// Inner's default redirects up to Outer. After entering
// Inner, this default fires as an up-transition that
// exits Inner. Outer is not re-entered.
default(Outer);
}
}
}
}
impl AncestorDefaultActions for AncestorDefaultActionContext<'_> {
async fn outer_in(&mut self) {}
async fn inner_in(&mut self) {}
async fn inner_out(&mut self) {}
}
const AD_ROOT: u16 = 0;
const AD_OUTER: u16 = 1;
const AD_INNER: u16 = 2;
#[tokio::test(flavor = "current_thread")]
async fn det_initial_lands_at_outer_no_default_descent() {
// Outer has no default of its own — initial entry stops at Outer.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = AncestorDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(m.current_state(), AncestorDefaultState::Outer);
let j = m.take_journal();
let entered: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(entered, vec![AD_ROOT, AD_OUTER]);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_explicit_into_inner_bounces_back_via_up_default() {
// `=> Inner` enters Inner (entries fire), then Inner's default fires
// as an up-transition Inner → Outer. Outer is NOT re-entered (it
// was already on the active path).
tokio::task::LocalSet::new()
.run_until(async {
let mut m = AncestorDefault::new(Ctx);
let _ = m.dispatch(Ev::GoIn).await;
assert_eq!(
m.current_state(),
AncestorDefaultState::Outer,
"Inner's default bounces us back to Outer"
);
let j = m.take_journal();
// Inner was entered (its entries fired) and then exited
// (its default fired an up-transition).
let entered_states: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
// Across the full journal: Root, Outer, Inner. After Inner's
// up-default to Outer, Outer is NOT re-entered.
assert_eq!(entered_states, vec![AD_ROOT, AD_OUTER, AD_INNER]);
let exited_states: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(
exited_states,
vec![AD_INNER],
"only Inner exits; Outer remains on the active path"
);
// Outer's entry actions ran exactly once (initial entry only).
let outer_entries = j
.iter()
.filter(|e| {
matches!(
e,
TraceEvent::ActionInvoked {
state: AD_OUTER,
kind: hsmc::ActionKind::Entry,
..
}
)
})
.count();
assert_eq!(outer_entries, 1, "Outer is entered once and stays put");
})
.await;
}
+90
View File
@@ -0,0 +1,90 @@
#![allow(dead_code)]
//! Deterministic-flow tests: `default(...)` chains. Hub → Spoke1 →
//! Spoke2. Each link fires as a real LCA-aware transition. The
//! compile-time default-graph cycle check guarantees this terminates.
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Halt,
}
statechart! {
ChainedDefault {
context: Ctx;
events: Ev;
default(Hub);
terminate(Halt);
state Hub {
entry: hub_in;
default(Spoke1);
}
state Spoke1 {
entry: s1_in;
exit: s1_out;
default(Spoke2);
}
state Spoke2 {
entry: s2_in;
}
}
}
impl ChainedDefaultActions for ChainedDefaultActionContext<'_> {
async fn hub_in(&mut self) {}
async fn s1_in(&mut self) {}
async fn s1_out(&mut self) {}
async fn s2_in(&mut self) {}
}
const CD_ROOT: u16 = 0;
const CD_HUB: u16 = 1;
const CD_SPOKE1: u16 = 2;
const CD_SPOKE2: u16 = 3;
#[tokio::test(flavor = "current_thread")]
async fn det_chained_defaults_terminate_at_chain_end() {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = ChainedDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(m.current_state(), ChainedDefaultState::Spoke2);
let j = m.take_journal();
let entered: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(entered, vec![CD_ROOT, CD_HUB, CD_SPOKE1, CD_SPOKE2]);
let exited: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect();
// Each default fires as a sibling-transition: Hub → Spoke1
// (exits Hub), then Spoke1 → Spoke2 (exits Spoke1).
assert_eq!(exited, vec![CD_HUB, CD_SPOKE1]);
// At least 2 TransitionFired events for the chained defaults.
let txn_count = j
.iter()
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.count();
assert!(txn_count >= 2);
})
.await;
}
+133
View File
@@ -0,0 +1,133 @@
#![allow(dead_code)]
//! Deterministic-flow tests: `default(...)` targeting a SIBLING fires as
//! a real LCA-aware transition immediately after the declaring state's
//! entries finish. Foyer's `default(LivingRoom)` means: enter Foyer,
//! run its entries, then immediately transition Foyer → LivingRoom
//! (LCA = Root, exits Foyer, enters LivingRoom).
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Halt,
}
statechart! {
SiblingDefault {
context: Ctx;
events: Ev;
default(Foyer);
terminate(Halt);
state Foyer {
entry: foyer_in;
exit: foyer_out;
default(LivingRoom);
}
state LivingRoom {
entry: living_in;
exit: living_out;
}
}
}
impl SiblingDefaultActions for SiblingDefaultActionContext<'_> {
async fn foyer_in(&mut self) {}
async fn foyer_out(&mut self) {}
async fn living_in(&mut self) {}
async fn living_out(&mut self) {}
}
const SD_ROOT: u16 = 0;
const SD_FOYER: u16 = 1;
const SD_LIVING: u16 = 2;
fn entered(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect()
}
fn exited(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect()
}
#[tokio::test(flavor = "current_thread")]
async fn det_sibling_default_exits_declarer_lands_at_target() {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = SiblingDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(m.current_state(), SiblingDefaultState::LivingRoom);
let j = m.take_journal();
assert_eq!(
entered(&j),
vec![SD_ROOT, SD_FOYER, SD_LIVING],
"Root → Foyer → (default fires) → LivingRoom"
);
assert_eq!(
exited(&j),
vec![SD_FOYER],
"Foyer is exited as the default transition's `from` state"
);
// Foyer's entry must run before its exit (default fires after entries).
let action_seq: Vec<(u16, ActionKind)> = j
.iter()
.filter_map(|e| match e {
TraceEvent::ActionInvoked { state, kind, .. } => Some((*state, *kind)),
_ => None,
})
.collect();
let foyer_entry_idx = action_seq
.iter()
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Entry)
.unwrap();
let foyer_exit_idx = action_seq
.iter()
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Exit)
.unwrap();
let living_entry_idx = action_seq
.iter()
.position(|(s, k)| *s == SD_LIVING && *k == ActionKind::Entry)
.unwrap();
assert!(foyer_entry_idx < foyer_exit_idx);
assert!(foyer_exit_idx < living_entry_idx);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_sibling_default_emits_transition_observation() {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = SiblingDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
let j = m.take_journal();
let txn_count = j
.iter()
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.count();
assert!(
txn_count >= 1,
"expected at least one TransitionFired for the default-as-transition firing"
);
})
.await;
}
+68 -4
View File
@@ -106,7 +106,9 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
#[tokio::test(flavor = "current_thread")]
async fn det_descent_target_with_three_default_levels() {
// Idle → Tower: Tower has default chain L1 → L2 → L3.
// Idle → Tower: Tower's `default(L1)` fires as a transition, L1's
// `default(L2)` fires, L2's `default(L3)` fires. Each link is a
// real transition with its own TransitionFired/Complete pair.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Desc::new(Ctx);
@@ -119,9 +121,19 @@ async fn det_descent_target_with_three_default_levels() {
},
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR },
// Root → Idle fires as Root's default.
TraceEvent::TransitionFired {
from: Some(SR),
to: SI,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SI },
entry(SI, A_IDLE_IN),
TraceEvent::Entered { state: SI },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SI,
},
TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered {
handler_state: SI,
@@ -138,15 +150,45 @@ async fn det_descent_target_with_three_default_levels() {
TraceEvent::EnterBegan { state: ST },
entry(ST, A_TOWER_IN),
TraceEvent::Entered { state: ST },
// Tower's default(L1) fires.
TraceEvent::TransitionFired {
from: Some(ST),
to: SL1,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SL1 },
entry(SL1, A_L1_IN),
TraceEvent::Entered { state: SL1 },
TraceEvent::TransitionComplete {
from: Some(ST),
to: SL1,
},
// L1's default(L2) fires.
TraceEvent::TransitionFired {
from: Some(SL1),
to: SL2,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SL2 },
entry(SL2, A_L2_IN),
TraceEvent::Entered { state: SL2 },
TraceEvent::TransitionComplete {
from: Some(SL1),
to: SL2,
},
// L2's default(L3) fires.
TraceEvent::TransitionFired {
from: Some(SL2),
to: SL3,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SL3 },
entry(SL3, A_L3_IN),
TraceEvent::Entered { state: SL3 },
TraceEvent::TransitionComplete {
from: Some(SL2),
to: SL3,
},
TraceEvent::TransitionComplete {
from: Some(SI),
to: ST,
@@ -164,11 +206,22 @@ async fn det_descent_entries_fire_outer_to_inner() {
.run_until(async {
let mut m = Desc::new(Ctx);
let _ = m.dispatch(Ev::Go).await;
// After the GO transition: order of Entered events should be Tower → L1 → L2 → L3.
// After the GO event-driven transition: order of Entered
// events should be Tower → L1 → L2 → L3. We split on the
// event-driven TransitionFired specifically (initial-entry
// defaults emit TransitionFired{Internal} too).
let split = m
.journal()
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
let entered: Vec<u16> = m.journal()[split..]
.iter()
@@ -210,10 +263,21 @@ async fn det_descent_action_kind_sequence() {
.run_until(async {
let mut m = Desc::new(Ctx);
let _ = m.dispatch(Ev::Go).await;
// Split on the event-driven TransitionFired (the initial
// default-fire is also TransitionFired{Internal} but happens
// before GO).
let split = m
.journal()
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
// After the Idle exit, all subsequent ActionInvoked must be Entry kind.
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..]
+40 -1
View File
@@ -19,7 +19,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)]
pub struct Ctx;
@@ -124,24 +124,63 @@ async fn det_initial_descent_records_full_path() {
let actual = m.take_journal();
let expected = vec![
// Initial enter (driven by dispatch's prime-state branch).
// Each `default(...)` is a real transition, so the journal
// brackets every default-fire with TransitionFired
// (Internal) / TransitionComplete pairs.
TraceEvent::Started {
chart_hash: Init4::<8>::CHART_HASH,
},
TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN),
TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SA,
},
TraceEvent::TransitionFired {
from: Some(SA),
to: SB,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
TraceEvent::TransitionFired {
from: Some(SB),
to: SC,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SC,
},
TraceEvent::TransitionFired {
from: Some(SC),
to: SD,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
TraceEvent::TransitionComplete {
from: Some(SC),
to: SD,
},
// Halt.
TraceEvent::EventReceived { event: E_HALT },
TraceEvent::TerminateRequested { event: E_HALT },
+39 -2
View File
@@ -140,7 +140,8 @@ fn handler(state: u16, action: u16) -> TraceEvent {
}
}
// Initial descent: Root → Parent → A.
// Initial descent: Root → (Root's default fires) → Parent → (Parent's
// default fires) → A. Each default-fire is a real transition.
fn initial_descent() -> Vec<TraceEvent> {
vec![
TraceEvent::Started {
@@ -148,12 +149,30 @@ fn initial_descent() -> Vec<TraceEvent> {
},
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SP,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SP },
entry(SP, A_P_IN),
TraceEvent::Entered { state: SP },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SP,
},
TraceEvent::TransitionFired {
from: Some(SP),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
TraceEvent::TransitionComplete {
from: Some(SP),
to: SA,
},
]
}
@@ -285,9 +304,19 @@ async fn det_lateral_target_with_children_default_descends() {
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
// D's `default(E)` fires as an internal transition.
TraceEvent::TransitionFired {
from: Some(SD),
to: SE,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SE },
entry(SE, A_E_IN),
TraceEvent::Entered { state: SE },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SE,
},
TraceEvent::TransitionComplete {
from: Some(SA),
to: SD,
@@ -471,7 +500,15 @@ async fn det_lateral_entry_outer_then_inner() {
let entries: Vec<u16> = m
.journal()
.iter()
.skip_while(|e| !matches!(e, TraceEvent::TransitionFired { .. }))
.skip_while(|e| {
!matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
+218
View File
@@ -0,0 +1,218 @@
#![allow(dead_code)]
//! Deterministic-flow tests for OPTIONAL `default(...)` on composite states.
//!
//! Spec: a composite state may declare `default(...)` or omit it. When
//! omitted, transitions targeting the composite land on the composite itself
//! — no default-descent. Substates are reachable only via explicit
//! transitions. This is the "microwave" pattern: a `Standby` state with
//! sub-modes (e.g. `LowPower`) that the chart enters only on demand.
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
PowerOn,
PowerOff,
EnterLowPower,
LeaveLowPower,
Halt,
}
statechart! {
Microwave {
context: Ctx;
events: Ev;
default(Standby);
terminate(Halt);
state Standby {
// No `default(...)` — Standby itself is the resting state.
entry: standby_in;
exit: standby_out;
on(PowerOn) => On;
on(EnterLowPower) => LowPower;
state LowPower {
entry: low_in;
exit: low_out;
on(LeaveLowPower) => Standby;
}
}
state On {
entry: on_in;
exit: on_out;
on(PowerOff) => Standby;
}
}
}
impl MicrowaveActions for MicrowaveActionContext<'_> {
async fn standby_in(&mut self) {}
async fn standby_out(&mut self) {}
async fn low_in(&mut self) {}
async fn low_out(&mut self) {}
async fn on_in(&mut self) {}
async fn on_out(&mut self) {}
}
// State ids: 0=Root, 1=Standby, 2=LowPower, 3=On.
const SR: u16 = 0;
const S_STANDBY: u16 = 1;
const S_LOW: u16 = 2;
const S_ON: u16 = 3;
fn entered_states(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect()
}
fn exited_states(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect()
}
fn entry_action_states(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::ActionInvoked {
state,
kind: ActionKind::Entry,
..
} => Some(*state),
_ => None,
})
.collect()
}
#[tokio::test(flavor = "current_thread")]
async fn det_initial_lands_on_composite_no_descent() {
// Standby has children but no `default` — initial entry stops at Standby.
// LowPower must NOT enter.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
// Drive initial entry without consuming an event.
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(
m.current_state(),
MicrowaveState::Standby,
"innermost active state is the composite Standby"
);
let j = m.take_journal();
let entered = entered_states(&j);
assert_eq!(
entered,
vec![SR, S_STANDBY],
"initial entry stops at Standby; no default-descent into LowPower"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_transition_into_substate_works() {
// Explicit `=> LowPower` from Standby enters LowPower.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::EnterLowPower).await;
assert_eq!(m.current_state(), MicrowaveState::LowPower);
let j = m.take_journal();
// Standby entered first (initial), then LowPower entered after the event.
let entered = entered_states(&j);
assert_eq!(entered, vec![SR, S_STANDBY, S_LOW]);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_up_from_substate_lands_on_composite() {
// From LowPower, `=> Standby` is an up-transition: exit LowPower, do not
// re-enter Standby.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::EnterLowPower).await;
let _ = m.dispatch(Ev::LeaveLowPower).await;
assert_eq!(m.current_state(), MicrowaveState::Standby);
let j = m.take_journal();
// Across the whole run: enter Standby, enter LowPower, exit
// LowPower. Standby's entry must have run exactly once.
let entry_states = entry_action_states(&j);
let standby_entries = entry_states.iter().filter(|&&s| s == S_STANDBY).count();
assert_eq!(
standby_entries, 1,
"Standby's entry runs once; up-transition does not re-enter it"
);
let exited = exited_states(&j);
assert_eq!(
exited,
vec![S_LOW],
"only LowPower is exited; Standby remains on the active path"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_lateral_to_composite_does_not_descend() {
// From On, `=> Standby` lands on Standby — NOT on LowPower.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::PowerOn).await;
assert_eq!(m.current_state(), MicrowaveState::On);
let _ = m.dispatch(Ev::PowerOff).await;
assert_eq!(
m.current_state(),
MicrowaveState::Standby,
"PowerOff lands on Standby; absence of `default` skips descent"
);
let j = m.take_journal();
// LowPower must never have been entered.
let entered = entered_states(&j);
assert!(
!entered.contains(&S_LOW),
"no descent into LowPower on entry to Standby"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_terminate_from_composite_unwinds_path() {
// Halt while at Standby exits Standby → Root only.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::Halt).await;
let j = m.take_journal();
let exits = exited_states(&j);
assert_eq!(
exits,
vec![S_STANDBY, SR],
"terminate from Standby exits Standby then Root"
);
assert!(matches!(j.last(), Some(TraceEvent::Terminated)));
})
.await;
}
+12 -3
View File
@@ -13,7 +13,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)]
pub struct Ctx;
@@ -293,10 +293,19 @@ async fn det_ord_full_transition_canonical_sequence() {
let mut m = Ord::new(Ctx);
let _ = m.dispatch(Ev::Go).await;
let j = m.take_journal();
// Find the TransitionFired and capture everything until A's exit and B's entry are done.
// Find the EVENT-driven TransitionFired (initial default-fires
// are TransitionFired{Internal}; we want the GO transition).
let trans_idx = j
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
let kinds: Vec<ActionKind> = j[trans_idx..]
.iter()
+33 -2
View File
@@ -104,12 +104,32 @@ async fn det_root_targetable_by_chart_name() {
TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN),
TraceEvent::Entered { state: SR },
// Root's `default(A)` fires.
TraceEvent::TransitionFired {
from: Some(SR),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SA,
},
// A's `default(B)` fires.
TraceEvent::TransitionFired {
from: Some(SA),
to: SB,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
TraceEvent::EventReceived { event: E_UPTOROOT },
TraceEvent::EventDelivered {
handler_state: SB,
@@ -148,10 +168,21 @@ async fn det_root_target_does_not_re_enter_root() {
let mut m = Sub::new(Ctx);
let _ = m.dispatch(Ev::UpToRoot).await;
let j = m.take_journal();
// After the TransitionFired, no Entered event should fire.
// After the EVENT-driven TransitionFired (the UpToRoot one),
// no Entered event should fire — the up-transition exits B
// and A but never re-enters root, and root has no default
// (so no internal default-fire follows either).
let split = j
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
let entered_after = j[split..]
.iter()
+18
View File
@@ -88,12 +88,30 @@ fn initial_descent() -> Vec<TraceEvent> {
},
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SM,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SM },
entry(SM, A_M_IN),
TraceEvent::Entered { state: SM },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SM,
},
TraceEvent::TransitionFired {
from: Some(SM),
to: SL,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SL },
entry(SL, A_L_IN),
TraceEvent::Entered { state: SL },
TraceEvent::TransitionComplete {
from: Some(SM),
to: SL,
},
]
}
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
+49 -1
View File
@@ -113,6 +113,8 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
}
}
fn initial_descent() -> Vec<TraceEvent> {
// Each `default(...)` fires as a TransitionFired{Internal} /
// TransitionComplete pair around the entered state.
vec![
TraceEvent::Started {
chart_hash: Up::<8>::CHART_HASH,
@@ -120,18 +122,54 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN),
TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SA,
},
TraceEvent::TransitionFired {
from: Some(SA),
to: SB,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
TraceEvent::TransitionFired {
from: Some(SB),
to: SC,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SC,
},
TraceEvent::TransitionFired {
from: Some(SC),
to: SD,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
TraceEvent::TransitionComplete {
from: Some(SC),
to: SD,
},
]
}
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
@@ -361,9 +399,19 @@ async fn det_up_current_state_is_target_after() {
.rev()
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
.map(|(i, _)| i);
// Find the EVENT-driven UpToB transition (initial defaults
// emit TransitionFired{Internal} too).
let split = j
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
// The last Entered event must be BEFORE the transition.
assert!(
+21
View File
@@ -104,6 +104,12 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_ROOT },
// Root's `default(Idle)` fires as an internal transition.
TraceEvent::TransitionFired {
from: Some(ST_ROOT),
to: ST_IDLE,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: ST_IDLE },
TraceEvent::ActionInvoked {
state: ST_IDLE,
@@ -111,6 +117,10 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_IDLE },
TraceEvent::TransitionComplete {
from: Some(ST_ROOT),
to: ST_IDLE,
},
// ── Go event delivered to Idle, transition to Active → Sub ──
TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered {
@@ -136,6 +146,13 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_ACTIVE },
// Active's `default(Sub)` fires as an internal transition,
// bracketed by its own TransitionFired/Complete.
TraceEvent::TransitionFired {
from: Some(ST_ACTIVE),
to: ST_SUB,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: ST_SUB },
TraceEvent::ActionInvoked {
state: ST_SUB,
@@ -143,6 +160,10 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_SUB },
TraceEvent::TransitionComplete {
from: Some(ST_ACTIVE),
to: ST_SUB,
},
TraceEvent::TransitionComplete {
from: Some(ST_IDLE),
to: ST_ACTIVE,