diff --git a/README.md b/README.md index 9cbcae1..44cf7c5 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/002. hsmc-semantics-formal.md b/docs/002. hsmc-semantics-formal.md index 4b43c73..891ccdc 100644 --- a/docs/002. hsmc-semantics-formal.md +++ b/docs/002. hsmc-semantics-formal.md @@ -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()` | 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()` | 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). | diff --git a/hsmc-macros/src/codegen.rs b/hsmc-macros/src/codegen.rs index 098ed0e..0f43668 100644 --- a/hsmc-macros/src/codegen.rs +++ b/hsmc-macros/src/codegen.rs @@ -100,21 +100,27 @@ type TransitionPathTables = (Vec, Vec, 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, Vec, Vec<(u16, u16)>, Vec<(u16, u16) fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables { let n = states.len(); let parent = |s: u16| -> Option { states[s as usize].parent }; - let default_child = |s: u16| -> Option { 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 { @@ -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 = 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 = 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, Option)> { 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)> { +/// 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)> { 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, 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 { 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 { /// (no `events:` declared, no channel). The default arm races only the timer. fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec { 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) { /// compiles unchanged. fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec { 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 { /// durings on a timer-only path. fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec { 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 = active_durings_per_leaf(ir) + let states_with_active_durings: Vec = 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; } } } diff --git a/hsmc-macros/src/ir.rs b/hsmc-macros/src/ir.rs index e5ffb7c..353ec95 100644 --- a/hsmc-macros/src/ir.rs +++ b/hsmc-macros/src/ir.rs @@ -79,7 +79,17 @@ pub struct StateIr { pub name: Ident, pub parent: Option, 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, + /// 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, // action ids pub exits: Vec, pub handlers: Vec, @@ -192,6 +202,7 @@ fn alloc_state(ir: &mut Ir, name: Ident, parent: Option, 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(()) } diff --git a/hsmc-macros/src/validate.rs b/hsmc-macros/src/validate.rs index 93fff48..4ec8338 100644 --- a/hsmc-macros/src/validate.rs +++ b/hsmc-macros/src/validate.rs @@ -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 = 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 = 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)?; diff --git a/hsmc-macros/tests/ui/t9_2_missing_default.rs b/hsmc-macros/tests/ui/t9_2_missing_default.rs deleted file mode 100644 index 8a78d47..0000000 --- a/hsmc-macros/tests/ui/t9_2_missing_default.rs +++ /dev/null @@ -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() {} diff --git a/hsmc-macros/tests/ui/t9_2_missing_default.stderr b/hsmc-macros/tests/ui/t9_2_missing_default.stderr deleted file mode 100644 index 3655622..0000000 --- a/hsmc-macros/tests/ui/t9_2_missing_default.stderr +++ /dev/null @@ -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 { - | ^^^^^^ diff --git a/hsmc-macros/tests/ui/t9_4_default_non_child.rs b/hsmc-macros/tests/ui/t9_4_default_non_child.rs deleted file mode 100644 index 25ca2f4..0000000 --- a/hsmc-macros/tests/ui/t9_4_default_non_child.rs +++ /dev/null @@ -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() {} diff --git a/hsmc-macros/tests/ui/t9_4_default_non_child.stderr b/hsmc-macros/tests/ui/t9_4_default_non_child.stderr deleted file mode 100644 index 8124bac..0000000 --- a/hsmc-macros/tests/ui/t9_4_default_non_child.stderr +++ /dev/null @@ -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); - | ^^^^^^^ diff --git a/hsmc-macros/tests/ui/t9_5_default_childless.rs b/hsmc-macros/tests/ui/t9_4_default_self_cycle.rs similarity index 53% rename from hsmc-macros/tests/ui/t9_5_default_childless.rs rename to hsmc-macros/tests/ui/t9_4_default_self_cycle.rs index dca4bc3..9d93111 100644 --- a/hsmc-macros/tests/ui/t9_5_default_childless.rs +++ b/hsmc-macros/tests/ui/t9_4_default_self_cycle.rs @@ -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; } } diff --git a/hsmc-macros/tests/ui/t9_4_default_self_cycle.stderr b/hsmc-macros/tests/ui/t9_4_default_self_cycle.stderr new file mode 100644 index 0000000..69b4118 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_4_default_self_cycle.stderr @@ -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); + | ^^^^^^^ diff --git a/hsmc-macros/tests/ui/t9_5_default_childless.stderr b/hsmc-macros/tests/ui/t9_5_default_childless.stderr deleted file mode 100644 index 45acaba..0000000 --- a/hsmc-macros/tests/ui/t9_5_default_childless.stderr +++ /dev/null @@ -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); - | ^^^^^^^ diff --git a/hsmc-macros/tests/ui/t9_5_default_two_cycle.rs b/hsmc-macros/tests/ui/t9_5_default_two_cycle.rs new file mode 100644 index 0000000..960d855 --- /dev/null +++ b/hsmc-macros/tests/ui/t9_5_default_two_cycle.rs @@ -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() {} diff --git a/hsmc-macros/tests/ui/t9_5_default_two_cycle.stderr b/hsmc-macros/tests/ui/t9_5_default_two_cycle.stderr new file mode 100644 index 0000000..cd4ec7d --- /dev/null +++ b/hsmc-macros/tests/ui/t9_5_default_two_cycle.stderr @@ -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); + | ^^^^^^^ diff --git a/hsmc-macros/tests/ui_pass/composite_without_default.rs b/hsmc-macros/tests/ui_pass/composite_without_default.rs new file mode 100644 index 0000000..94d31d5 --- /dev/null +++ b/hsmc-macros/tests/ui_pass/composite_without_default.rs @@ -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); +} diff --git a/hsmc-macros/tests/ui_pass/default_targets_ancestor.rs b/hsmc-macros/tests/ui_pass/default_targets_ancestor.rs new file mode 100644 index 0000000..0b84a1e --- /dev/null +++ b/hsmc-macros/tests/ui_pass/default_targets_ancestor.rs @@ -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); +} diff --git a/hsmc-macros/tests/ui_pass/default_targets_sibling.rs b/hsmc-macros/tests/ui_pass/default_targets_sibling.rs new file mode 100644 index 0000000..9897b9b --- /dev/null +++ b/hsmc-macros/tests/ui_pass/default_targets_sibling.rs @@ -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); +} diff --git a/hsmc/src/journal.rs b/hsmc/src/journal.rs index 8be038e..bd8d2fb 100644 --- a/hsmc/src/journal.rs +++ b/hsmc/src/journal.rs @@ -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, } diff --git a/hsmc/tests/det_default_ancestor.rs b/hsmc/tests/det_default_ancestor.rs new file mode 100644 index 0000000..c940dd7 --- /dev/null +++ b/hsmc/tests/det_default_ancestor.rs @@ -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 = 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 = 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 = 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; +} diff --git a/hsmc/tests/det_default_chained.rs b/hsmc/tests/det_default_chained.rs new file mode 100644 index 0000000..8850052 --- /dev/null +++ b/hsmc/tests/det_default_chained.rs @@ -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 = 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 = 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; +} diff --git a/hsmc/tests/det_default_sibling.rs b/hsmc/tests/det_default_sibling.rs new file mode 100644 index 0000000..15d878a --- /dev/null +++ b/hsmc/tests/det_default_sibling.rs @@ -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 { + j.iter() + .filter_map(|e| match e { + TraceEvent::Entered { state } => Some(*state), + _ => None, + }) + .collect() +} + +fn exited(j: &[TraceEvent]) -> Vec { + 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; +} diff --git a/hsmc/tests/det_descent.rs b/hsmc/tests/det_descent.rs index b92cb82..c9ba621 100644 --- a/hsmc/tests/det_descent.rs +++ b/hsmc/tests/det_descent.rs @@ -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 = 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 = m.journal()[split + 3..] diff --git a/hsmc/tests/det_initial.rs b/hsmc/tests/det_initial.rs index b597e95..724feee 100644 --- a/hsmc/tests/det_initial.rs +++ b/hsmc/tests/det_initial.rs @@ -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 }, diff --git a/hsmc/tests/det_lateral.rs b/hsmc/tests/det_lateral.rs index 805480e..99e9ae1 100644 --- a/hsmc/tests/det_lateral.rs +++ b/hsmc/tests/det_lateral.rs @@ -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 { vec![ TraceEvent::Started { @@ -148,12 +149,30 @@ fn initial_descent() -> Vec { }, 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 = 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, diff --git a/hsmc/tests/det_optional_default.rs b/hsmc/tests/det_optional_default.rs new file mode 100644 index 0000000..32f2555 --- /dev/null +++ b/hsmc/tests/det_optional_default.rs @@ -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 { + j.iter() + .filter_map(|e| match e { + TraceEvent::Entered { state } => Some(*state), + _ => None, + }) + .collect() +} + +fn exited_states(j: &[TraceEvent]) -> Vec { + j.iter() + .filter_map(|e| match e { + TraceEvent::Exited { state } => Some(*state), + _ => None, + }) + .collect() +} + +fn entry_action_states(j: &[TraceEvent]) -> Vec { + 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; +} diff --git a/hsmc/tests/det_ordering.rs b/hsmc/tests/det_ordering.rs index 48432f3..b9792fb 100644 --- a/hsmc/tests/det_ordering.rs +++ b/hsmc/tests/det_ordering.rs @@ -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 = j[trans_idx..] .iter() diff --git a/hsmc/tests/det_root_target.rs b/hsmc/tests/det_root_target.rs index 75d0df5..d48f097 100644 --- a/hsmc/tests/det_root_target.rs +++ b/hsmc/tests/det_root_target.rs @@ -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() diff --git a/hsmc/tests/det_self.rs b/hsmc/tests/det_self.rs index f55d689..83a8dc2 100644 --- a/hsmc/tests/det_self.rs +++ b/hsmc/tests/det_self.rs @@ -88,12 +88,30 @@ fn initial_descent() -> Vec { }, 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]) { diff --git a/hsmc/tests/det_up.rs b/hsmc/tests/det_up.rs index 0dfaa64..d7d4383 100644 --- a/hsmc/tests/det_up.rs +++ b/hsmc/tests/det_up.rs @@ -113,6 +113,8 @@ fn exit_(state: u16, action: u16) -> TraceEvent { } } fn initial_descent() -> Vec { + // 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::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!( diff --git a/hsmc/tests/journal_replay.rs b/hsmc/tests/journal_replay.rs index 6b214d7..74d2d7f 100644 --- a/hsmc/tests/journal_replay.rs +++ b/hsmc/tests/journal_replay.rs @@ -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,