2 Commits
Author SHA1 Message Date
swaits 9eb1d10b28 chore(release): 0.5.0 2026-04-28 21:03:05 -06:00
swaitsandClaude Opus 4.7 3fc0d7c57d 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>
2026-04-28 20:57:01 -06:00
34 changed files with 1453 additions and 401 deletions

No files matched your search

+60
View File
@@ -4,6 +4,66 @@ All notable changes to this workspace are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/). this project adheres to [Semantic Versioning](https://semver.org/).
## 0.5.0 — 2026-04-28
### `default(...)` is a real LCA-aware transition
`default(...)` is now modeled the same way 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:
- **Optional.** A state with children no longer must 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` 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 uniform
rule covers descent, redirection, and 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)`. The check guarantees default chains
terminate at runtime — entering any state in a cycle would loop
forever before reaching user code.
### 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. Charts that
hand-build expected journal sequences (replay-style tests) must
include these brackets around every default-fire — see the
per-test diffs in this release for the pattern.
- **Validation.** Two compile-time errors went away:
`default(<non-direct-child>)` is now valid (the target may be
anywhere), and `default` on a childless state is now valid
(the leaf transitions out immediately on entry). Code relying
on these as guard rails must adapt. A new compile-time error
takes their place: any cycle in the default graph is rejected
with a pinpoint message naming the cycle (e.g.
`default-transition cycle detected: A -> B -> A`).
### Other
- README and `docs/002. hsmc-semantics-formal.md` rewritten so the
default-as-transition framing is the primary description; the old
"automatic transition into a child" language is gone.
- New integration tests covering each branch of the new semantics:
`det_optional_default` (microwave pattern), `det_default_sibling`
(lateral default), `det_default_chained` (multi-link default
chain), `det_default_ancestor` (up-default landing on an
ancestor).
- New compile-fail UI fixtures for cycle detection
(`t9_4_default_self_cycle`, `t9_5_default_two_cycle`) and new
compile-pass fixtures for non-child default targets
(`composite_without_default`, `default_targets_sibling`,
`default_targets_ancestor`).
## 0.4.1 — 2026-04-28 ## 0.4.1 — 2026-04-28
### Performance — dispatch hot path ### Performance — dispatch hot path
Generated
+3 -3
View File
@@ -495,7 +495,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hsmc" name = "hsmc"
version = "0.4.1" version = "0.5.0"
dependencies = [ dependencies = [
"crossterm", "crossterm",
"defmt 0.3.100", "defmt 0.3.100",
@@ -515,7 +515,7 @@ dependencies = [
[[package]] [[package]]
name = "hsmc-macros" name = "hsmc-macros"
version = "0.4.1" version = "0.5.0"
dependencies = [ dependencies = [
"hsmc", "hsmc",
"proc-macro2", "proc-macro2",
@@ -526,7 +526,7 @@ dependencies = [
[[package]] [[package]]
name = "hsmc-verification" name = "hsmc-verification"
version = "0.4.1" version = "0.5.0"
dependencies = [ dependencies = [
"creusot-std", "creusot-std",
"hsmc", "hsmc",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["hsmc", "hsmc-macros", "verification"] members = ["hsmc", "hsmc-macros", "verification"]
[workspace.package] [workspace.package]
version = "0.4.1" version = "0.5.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
+73 -182
View File
@@ -28,7 +28,9 @@ statechart! {
## What it does ## What it does
- **Harel-style hierarchical states.** Nested states with `entry:`/`exit:` - **Harel-style hierarchical states.** Nested states with `entry:`/`exit:`
actions, LCA-aware transitions, default-child descent, `terminate`. actions, LCA-aware transitions, optional `default(...)` (a transition
that may target any state — descend, redirect to a sibling, bounce
to an ancestor — with a compile-time cycle check), `terminate`.
- **Event-driven transitions and actions.** Typed payload bindings: - **Event-driven transitions and actions.** Typed payload bindings:
`on(PacketRx { rssi: i16 }) => handler;`. `on(PacketRx { rssi: i16 }) => handler;`.
- **Timer triggers.** `on(after Duration::from_secs(5)) => Next;` and - **Timer triggers.** `on(after Duration::from_secs(5)) => Next;` and
@@ -50,12 +52,9 @@ statechart! {
records every action, transition, timer, and event in order — same records every action, transition, timer, and event in order — same
inputs, byte-identical journal. The runtime queue and timer table inputs, byte-identical journal. The runtime queue and timer table
carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge
mechanically (`just verify`). 159 tests pin every spec rule mechanically (`just verify`). The integration suite pins every
(journal sequences + trace-format regression + integration). spec rule with byte-equal expected-vs-actual journals, trace-format
- **Backward-compatible chart syntax.** Every v0.1 / v0.2 / v0.3 regression checks, and per-feature behavior tests.
statechart compiles under v0.4 unchanged. The 0.4 breaks are in
observability surface area only — see
[Migration](#migration-from-v03).
## How a chart behaves — the building blocks ## How a chart behaves — the building blocks
@@ -66,27 +65,51 @@ The full canonical reference lives at
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md); [`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md);
this section is the on-ramp. 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 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 to one innermost active state. If you're in `C` inside `B` inside `A`,
**simultaneously** in `Root`, `A`, `B`, and `C`. The root is always you're **simultaneously** in `Root`, `A`, `B`, and `C`. The root is
active until termination. 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. 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 If a behavior somewhere else seems weird, come back here — it's
probably the resolution. 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 Any state **may** declare one `default(...)`. It's a transition — same
state is entered, its entry actions run first, **then** the default LCA-aware algorithm as any event-triggered transition (rule 4) — that
child is entered, recursively, until a leaf. 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 The classic case is a composite descending to a leaf:
machine starts by entering `Root` → `A` → `B`, all on first step. ```
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 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, that state has a handler, it runs and the event is consumed. If not,
@@ -95,7 +118,7 @@ with no handler, the event is silently discarded.
Three corollaries: 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 - Multiple handlers in one state on the same trigger fire in
declaration order; the transition (if any) fires last. declaration order; the transition (if any) fires last.
- **Timers don't bubble.** A timer belongs to the state that declared - **Timers don't bubble.** A timer belongs to the state that declared
@@ -110,7 +133,9 @@ Any transition's algorithm is uniform:
The LCA itself is **not** exited. The LCA itself is **not** exited.
3. Walk **down** from the LCA to target, entering each state. The LCA 3. Walk **down** from the LCA to target, entering each state. The LCA
is **not** re-entered. 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 The LCA always exists because root is always an upper bound. State
names are globally unique across the chart, so a transition can target names are globally unique across the chart, so a transition can target
@@ -125,8 +150,9 @@ transition algorithm do nothing. You exit the states strictly between
current and target; the target itself is **not** re-entered. current and target; the target itself is **not** re-entered.
So from leaf `C` inside `B` inside `A`, an `on(Up) => A;` exits `C` 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 then `B`. `A`'s entry actions don't fire. `A`'s `default` does not
followed. `A`'s timers don't restart. `A`'s durings keep running. 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. You cannot enter a state you never left.
@@ -136,10 +162,12 @@ Direction is set by the path:
- **Entries** fire **outer-to-inner**: when entering down through a - **Entries** fire **outer-to-inner**: when entering down through a
path, the outermost state's entries fire first, then the next, then path, the outermost state's entries fire first, then the next, then
the leaf's. Within a single state, entry actions fire in declaration the innermost's. Within a single state, entry actions fire in
order. After a state's entries finish, its `during:` activities declaration order. After a state's entries finish, its `during:`
start. Then default-descent (rule 2) applies recursively. activities start. Then, if the state declared a `default(...)`,
- **Exits** fire **inner-to-outer**: leaf first, then parent, then it fires as a transition (rule 2) — chains continue until a state
with no default.
- **Exits** fire **inner-to-outer**: innermost first, then parent, then
grandparent. Within a single state, exit actions fire in declaration grandparent. Within a single state, exit actions fire in declaration
order. Before a state's exits begin, its `during:` activities are order. Before a state's exits begin, its `during:` activities are
cancelled. cancelled.
@@ -200,7 +228,7 @@ Add to `Cargo.toml`:
```toml ```toml
[dependencies] [dependencies]
hsmc = { version = "0.4", features = ["embassy"] } # or ["tokio"] hsmc = { version = "0.5", features = ["embassy"] } # or ["tokio"]
``` ```
Minimal timer-only machine: Minimal timer-only machine:
@@ -357,7 +385,7 @@ For `statechart! Foo { ... }` the macro emits:
| `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). | | `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
| `fn send(ev)` | Raw push, non-draining. For tests. | | `fn send(ev)` | Raw push, non-draining. For tests. |
| `sender() -> Sender` | Clone handle for other tasks / ISRs. | | `sender() -> Sender` | Clone handle for other tasks / ISRs. |
| `current_state()` | The active leaf state. | | `current_state()` | The innermost active state. |
| `is_terminated()` | True after `terminate` event. | | `is_terminated()` | True after `terminate` event. |
| `has_pending_events()` | Queue non-empty. | | `has_pending_events()` | Queue non-empty. |
| `context()` / `context_mut()` | Borrow the root context. | | `context()` / `context_mut()` | Borrow the root context. |
@@ -375,135 +403,6 @@ For `statechart! Foo { ... }` the macro emits:
`embassy` and `tokio` are mutually exclusive — enabling both is a compile `embassy` and `tokio` are mutually exclusive — enabling both is a compile
error. error.
## Migration from v0.3
The 0.4 breaks are in the observability surface area only; chart
syntax and runtime API are unchanged.
### 1. Trace prefix changed
Trace lines (when `trace-defmt` / `trace-log` / `trace-tracing` is
enabled) now use `[statechart:Name]` instead of `[Name]`. Update any
log filters or grep patterns. The verb after the prefix is now a
single hyphenated token followed by `key=value` pairs (logfmt).
### 2. `TraceEvent::TransitionFired` carries `reason`
Add `reason` to any pattern matches:
```rust
// before:
TraceEvent::TransitionFired { from, to } => …
// after:
TraceEvent::TransitionFired { from, to, reason } => …
// or, if you don't need it:
TraceEvent::TransitionFired { from, to, .. } => …
```
The new field carries `TransitionReason::Event { event }`,
`Timer { state, timer }`, or `Internal` so consumers can see what
triggered the transition.
### 3. New `TraceEvent` variants
If you `match` exhaustively on `TraceEvent`, add arms for the new
variants. Otherwise nothing changes:
- `EnterBegan { state }` — phase begin marker pairs with `Entered`.
- `ExitBegan { state }` — phase begin marker pairs with `Exited`.
- `TransitionComplete { from, to }` — pairs with `TransitionFired`.
- `EventReceived { event }` — fires when an event is popped from the
queue, before handler search.
These are pure observation — no runtime semantic change.
### 4. `trace;` keyword is a no-op
Charts that declared `trace;` continue to compile. The keyword no
longer gates emission — observation is now controlled purely by
which `trace-*` cargo feature you enable. You can leave `trace;` in
or remove it; both work.
### 5. Trace backends are no longer mutually exclusive
Previously, enabling more than one of `trace-defmt` / `trace-log` /
`trace-tracing` was a compile error. Now they fan out independently:
turn on whichever you want.
## Migration from v0.1
v0.1 statecharts continue to compile. New features are purely additive:
### 1. `events:` is now optional
Previously required; now omit for pure timer-only machines:
```rust
statechart! {
Blinker {
context: Ctx;
default(On);
state On { on(after Duration::from_millis(500)) => Off; }
state Off { on(after Duration::from_millis(500)) => On; }
}
}
```
### 2. `dispatch(ev).await` replaces the push+drain helper
Before (v0.1, in the dogfood firmware):
```rust
async fn push<M>(m: &mut M, ev: E) {
m.send(ev).expect("queue full");
while m.has_pending_events() {
m.step(Duration::ZERO).await;
}
}
push(&mut machine, ev).await;
```
After (v0.2):
```rust
machine.dispatch(ev).await.unwrap();
```
### 3. `during:` replaces the imperative I/O outer loop
Before — a radio task with per-state match and select:
```rust
loop {
match machine.current_state() {
RadioState::Receiving => {
match select(rx_once(&mut lora, &cfg, &mut rx_buf),
commands.receive()).await {
Either::First(r) => handle_rx(r, &mut machine, ...).await,
Either::Second(c) => handle_cmd(c, &mut machine, ...).await,
}
}
_ => { ... }
}
}
```
After — the statechart owns the loop:
```rust
state Receiving {
during: next_packet(lora, rx_buf);
on(PacketRx { rssi: i16, snr: i16 }) => record_packet;
on(StopRx) => Idle;
}
#[embassy_executor::task]
async fn radio_task(lora: LoRaDriver, ch: &'static RadioChan) {
let mut m = Radio::new(RadioCtx { lora, ... }, ch);
m.run().await.unwrap();
}
```
## Examples ## Examples
| Example | Purpose | | Example | Purpose |
@@ -535,13 +434,14 @@ spec-rule → proof mapping.
Beyond Creusot, the suite includes: Beyond Creusot, the suite includes:
- **159 integration tests** — full chart behavior, byte-equal expected- - **Integration tests** — full chart behavior and per-feature
vs-actual journals for ~113 deterministic-flow tests, plus 11 trace- determinism, with byte-equal expected-vs-actual journals on the
format regression tests pinning every observation verb's exact `det_*` deterministic-flow tests, plus trace-format regression
textual rendering. tests pinning every observation verb's textual rendering. Run
- **0 missed mutants** under `cargo mutants --features with `cargo test --workspace --features tokio,journal`.
- **`cargo mutants`** — full coverage under `cargo mutants --features
tokio,journal,trace-log`. tokio,journal,trace-log`.
- **Miri**: 68 tests, no UB. - **Miri** — clean, no UB on the runtime crates.
## Observability — one journal, multiple outputs ## Observability — one journal, multiple outputs
@@ -611,25 +511,16 @@ diffed against expected behavior the same way the journal can.
## Status ## Status
- v0.1 spec: fully implemented. Pre-1.0. Per-release breaking changes are documented in
- v0.2: `during:`, optional `events:`, `dispatch()`, `STATE_CHART`, [`CHANGELOG.md`](CHANGELOG.md).
drain-on-send. Up-transitions no longer exit/re-enter the target.
- v0.3: `journal` feature + deterministic trace + replay; root Out of scope (deliberate): guard conditions, orthogonal/parallel
state targetable by chart name; Creusot verification of the regions, history states, deferred events, internal transitions,
runtime data structures; 113-test deterministic-flow suite. state-local context, event priorities beyond FIFO, runtime
- v0.4: unified observation pipeline (journal + trace share one statechart modification, multi-threaded tokio. See
vocabulary, fan out to multiple sinks at compile time); logfmt- [`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md)
style trace format with `[statechart:Name]` prefix; transition § "What `hsmc` deliberately does NOT have" for the rationale and
reasons; entry/exit/transition begin/end markers; multiple trace what to use instead.
backends now compose; chart-level `trace;` keyword deprecated.
Breaking on the `TraceEvent` enum shape; chart syntax unchanged.
- Out of scope (deliberate): guard conditions, orthogonal/parallel
regions, history states, deferred events, internal transitions,
state-local context, event priorities beyond FIFO, runtime
statechart modification, multi-threaded tokio. See
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md)
§ "What `hsmc` deliberately does NOT have" for the rationale and
what to use instead.
## License ## License
+42 -24
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. to the target, calling entries along the way.
- **Action (without transition)** — a function call attached to a trigger. - **Action (without transition)** — a function call attached to a trigger.
No state change. That's it. No state change. That's it.
- **Default child** — every non-leaf state declares one default child. - **Default transition** — any state **may** declare one `default(...)`.
Whenever a state with children is entered, the default child is entered It's a transition (same LCA-aware algorithm as rule 4 / event-driven
immediately after that state's entry actions finish — it's effectively transitions) that fires immediately after the declaring state's entry
an automatic transition into the child. 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 - **Trigger: event vs. duration** — an event is a variant of your event
enum. A duration is a timer (`after Duration` fires once, `every enum. A duration is a timer (`after Duration` fires once, `every
Duration` fires repeatedly). Triggers attach to transitions and to 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 `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 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.
--- ---
@@ -86,8 +93,9 @@ and still nothing handles it, the event is silently discarded.
That's all. Three other things follow from this rule: That's all. Three other things follow from this rule:
- A handler on a leaf state shadows a handler on its ancestor for the same - A handler on the innermost active state shadows a handler on any
event. The leaf wins because the search starts there. ancestor for the same event. The innermost wins because the search
starts there.
- If a single state has multiple handlers (actions and/or a transition) - If a single state has multiple handlers (actions and/or a transition)
for the same trigger, the actions fire in declaration order, then the for the same trigger, the actions fire in declaration order, then the
transition fires last. transition fires last.
@@ -108,8 +116,8 @@ in `statechart! { Foo { ... } }`, an `on(Trig) => Foo;` is a valid
transition whose target is the root. From any state inside the chart this transition whose target is the root. From any state inside the chart this
is an up-transition (root is always on the active path) — exiting only is an up-transition (root is always on the active path) — exiting only
the states strictly between current and root, with no entry actions and the states strictly between current and root, with no entry actions and
no default-descent on root. Use this when you want to wind everything no default firing on root (the root wasn't freshly entered). Use this
back up to the chart's own scope. when you want to wind everything back up to the chart's own scope.
To run a transition: To run a transition:
@@ -121,8 +129,10 @@ To run a transition:
itself is **not** exited. itself is **not** exited.
3. Walk **down** from the LCA to the target, entering each state on the 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. 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 4. If the target declares a `default(...)`, fire it as a recursive
leaf, entering each as you go. 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 The three flavors below are just three relationships between current and
target. The same algorithm covers all of them — they only look different target. The same algorithm covers all of them — they only look different
@@ -131,8 +141,9 @@ in which steps fire.
### Transitioning to a sibling (or anywhere not on your active path) ### Transitioning to a sibling (or anywhere not on your active path)
The standard case. LCA is somewhere strictly above both current and 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. Exit up to LCA, enter down to target, then if the target
target has children. Already covered above. declares a `default(...)`, fire it as a chained transition. Already
covered above.
### Transitioning to yourself ### Transitioning to yourself
@@ -153,11 +164,12 @@ Timers declared on the target restart, because the target itself was
exited and re-entered. exited and re-entered.
If the target happens to be a composite (it has children, but no child is 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 active right now because you got there via an up-transition or because
default-child rule still applies on re-entry: exit the composite, re-enter the composite has no `default`), the same rule still applies on re-entry:
it, then descend through its default chain. Self-transitions don't add a exit the composite, re-enter it, then descend through its default chain
new rule — they're just the case where source and target are the same if it declares one. A composite without `default` is exited, re-entered,
state. 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 Personally I don't find self-transitions all that valuable, but people
talk about them all the time, so they're supported. talk about them all the time, so they're supported.
@@ -207,7 +219,11 @@ Putting it all together:
outermost state's entry actions fire first, then its child's, and so on 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 down to the innermost. Within a single state, entry actions fire in
declaration order. After entry actions of a state finish, that state's 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 - **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 innermost state exits first, then its parent, and so on up. Within a
single state, exit actions fire in declaration order. Before exit single state, exit actions fire in declaration order. Before exit
@@ -367,11 +383,13 @@ drafts. Edit freely.)_
| Scenario | What fires | | Scenario | What fires |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Initial machine start | Root entries → default-descend down through each child's entries → durings start at each entered state. | | 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) → default-descend if target has children. | | 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 innermost has children, default-descend after re-entry. | | 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. No default-descent. Ancestor's durings and timers continue. | | 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 children | After the target's entries fire, default-descend through each default child's entries down to a leaf. | | 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). | | Timer fires | The declaring state's handler runs (timers don't bubble). |
| Event with no handler | Silently discarded. | | Event with no handler | Silently discarded. |
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). | | 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. /// in `transition()` with two slice lookups and two straight slice iterations.
/// ///
/// The (from, to) lookup matches the runtime LCA call: `from` is the active /// The (from, to) lookup matches the runtime LCA call: `from` is the active
/// leaf (`self.current`), `to` is the transition target. The same three /// innermost state (`self.current`), `to` is the transition target. The
/// transition shapes are encoded uniformly by what the precomputed paths /// same three transition shapes are encoded uniformly by what the
/// contain: /// precomputed paths contain:
/// ///
/// - **Up-transition** (`to` is a strict ancestor of `from`): exit_path is /// - **Up-transition** (`to` is a strict ancestor of `from`): exit_path is
/// `from..to` (target excluded; `to` is already on the active path and /// `from..to` (target excluded; `to` is already on the active path and
/// must not be re-entered). enter_path is empty — `transition()` sets /// must not be re-entered). enter_path is empty — `transition()` sets
/// `self.current = Some(target)` after the loops. /// `self.current = Some(target)` after the loops.
/// - **Self-transition** (`from == to`): exit_path is `[to]`. enter_path /// - **Self-transition** (`from == to`): exit_path is `[to]`. enter_path
/// is `[to, default_child(to), default_child(default_child(to)), ...]` /// is `[to]` — re-enter the target.
/// — re-enter target plus descend defaults.
/// - **Lateral** (LCA is a proper ancestor of both): exit_path walks /// - **Lateral** (LCA is a proper ancestor of both): exit_path walks
/// `from` up to but not including LCA. enter_path walks /// `from` up to but not including LCA. enter_path walks
/// LCA-side child of `to` down to `to`, then continues descending /// LCA-side child of `to` down to `to`.
/// `__DEFAULT_CHILD` from `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; /// Storage: `__EXIT_DATA` and `__ENTER_DATA` are flat `[u16]` blobs;
/// `__EXIT_RANGE[i*N + j]` / `__ENTER_RANGE[i*N + j]` give `(start, end)` /// `__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 { fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
let n = states.len(); let n = states.len();
let parent = |s: u16| -> Option<u16> { states[s as usize].parent }; 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 depth = |s: u16| -> i32 { states[s as usize].depth as i32 };
let lca = |mut a: u16, mut b: u16| -> Option<u16> { 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()); return (exit_path, Vec::new());
} }
// Self-transition: external semantics — exit and re-enter target, // Self-transition: external semantics — exit and re-enter target.
// then descend defaults. // Default-following (if any) happens in the runtime chain loop.
if from == to { if from == to {
let exit_path = vec![to]; return (vec![to], 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);
} }
// Lateral: exit `from` up to (not including) LCA; enter from // 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 lca_id = lca(from, to);
let mut exit_path = Vec::new(); let mut exit_path = Vec::new();
@@ -213,13 +212,7 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
None => break, None => break,
} }
} }
let mut enter_path: Vec<u16> = rev.into_iter().rev().collect(); let 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);
}
(exit_path, enter_path) (exit_path, enter_path)
}; };
@@ -260,8 +253,8 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
(exit_data, enter_data, exit_range, enter_range) (exit_data, enter_data, exit_range, enter_range)
} }
/// Static precompute of `do_terminate()`'s exit walk: for each leaf state, /// Static precompute of `do_terminate()`'s exit walk: for each state, the
/// the chain `[current, parent(current), ..., __Root]` (root inclusive, /// chain `[current, parent(current), ..., __Root]` (root inclusive,
/// since `__Root` may carry root-level `entry:`/`exit:` actions declared /// since `__Root` may carry root-level `entry:`/`exit:` actions declared
/// at the chart top level — see the `det_initial` test for that pattern). /// 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 out
} }
/// For each user-declared leaf state that has one or more `during:` activities /// For each state that can be the innermost active state, return its state id
/// active (either declared on it, on any ancestor, or on root), return its /// and the flattened list of `during:` activities active there (declared on it
/// state id and the flattened list of durings in leaf-first order. Durings /// or on any ancestor) in leaf-first order. Durings declared on the same state
/// declared on the same state follow their declaration order. /// follow their declaration order.
/// ///
/// Leaf states with no active durings are omitted — the run loop uses its /// A state can be innermost iff it has no children, OR it has children but no
/// default (channel+timer) path for those. /// `default` child (so default-descent stops at it). Composites with a default
fn active_durings_per_leaf(ir: &Ir) -> Vec<(u16, Vec<During>)> { /// 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(); let mut out = Vec::new();
for s in &ir.states { for s in &ir.states {
if !s.children.is_empty() { if !s.children.is_empty() && s.default_child.is_some() {
continue; continue;
} }
if s.parent.is_none() { if s.parent.is_none() {
@@ -425,14 +422,14 @@ fn emit_during_bindings(chain: &[During]) -> (Vec<Ident>, TokenStream) {
(idents, quote! { #(#stmts)* }) (idents, quote! { #(#stmts)* })
} }
/// Emit the per-leaf match arms for the tokio run loop. Each arm constructs /// Emit the per-state match arms for the tokio run loop, keyed on the
/// the active during: futures (split-borrowing context fields), then races /// innermost active state id. Each arm constructs the active during: futures
/// them against the event channel and the next-timer sleep via /// (split-borrowing context fields), then races them against the event channel
/// `tokio::select!`. The default arm — used for leaves with no durings — is /// and the next-timer sleep via `tokio::select!`. The default arm — used for
/// emitted by the caller. /// states with no active durings — is emitted by the caller.
fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> { fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
let mut arms = Vec::new(); 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 (binds, stmts) = emit_during_bindings(&chain);
let select_arms = binds.iter().map(|b| { let select_arms = binds.iter().map(|b| {
quote! { ev = #b => __HsmcRace::Event(ev), } 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. /// (no `events:` declared, no channel). The default arm races only the timer.
fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> { fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
let mut arms = Vec::new(); 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 (binds, stmts) = emit_during_bindings(&chain);
let select_arms = binds.iter().map(|b| { let select_arms = binds.iter().map(|b| {
quote! { ev = #b => __HsmcRace::Event(ev), } quote! { ev = #b => __HsmcRace::Event(ev), }
@@ -639,7 +636,7 @@ fn embassy_select_for_arity(arity: usize) -> (Ident, Vec<Ident>) {
/// compiles unchanged. /// compiles unchanged.
fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> { fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
let mut arms = Vec::new(); 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(); let n = chain.len();
if n + 2 > 6 { if n + 2 > 6 {
let msg = format!( let msg = format!(
@@ -703,7 +700,7 @@ fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
/// durings on a timer-only path. /// durings on a timer-only path.
fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> { fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
let mut arms = Vec::new(); 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(); let n = chain.len();
if n == 0 { if n == 0 {
continue; // falls through to default (just timer) continue; // falls through to default (just timer)
@@ -1104,8 +1101,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
__timer_reason_str(__t_state, __t_trigger) __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!( ::hsmc::__chart_observe!(
TransitionFired, TransitionFired,
&mut self.__journal, &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 // 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 // the current state has an active during, the run loop should keep
// awaiting rather than break (the during might yet produce an event). // 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() .into_iter()
.map(|(sid, _)| sid) .map(|(sid, _)| sid)
.collect(); .collect();
@@ -2595,7 +2591,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
/// Returns `true` once the machine has processed its `terminate` event (§4.4). /// Returns `true` once the machine has processed its `terminate` event (§4.4).
pub fn is_terminated(&self) -> bool { self.terminated } 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). /// first `step()` (§4.4).
pub fn current_state(&self) -> #state_enum_name { pub fn current_state(&self) -> #state_enum_name {
__state_id_to_public(self.current.expect("machine not started; call step() first")) __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 // An empty enter slice (`m == e`) means up-transition: target
// is already on the active path, must not be re-entered, and // is already on the active path, must not be re-entered, and
// `self.current` is set to `target` after the exit loop. A // `self.current` is set to `target` after the exit loop. A
// non-empty enter slice ends at the deepest default-descended // non-empty enter slice ends at the target itself —
// leaf; `enter_state_no_descent` sets `self.current` as it // `enter_state_no_descent` sets `self.current` as it goes.
// goes, so no fixup is needed on that path. // 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")))] #[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn transition(&mut self, _source_matched_state: u16, target: u16) { fn apply_path(&mut self, from: u16, target: u16) -> bool {
let from = self.current.unwrap_or(target);
let idx = (from as usize) * #n_states + (target as usize); let idx = (from as usize) * #n_states + (target as usize);
let (s, m, e) = __PATH_RANGE[idx]; let (s, m, e) = __PATH_RANGE[idx];
let exit_path = &__PATH_DATA[s as usize..m as usize]; 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 { for &sid in enter_path {
self.enter_state_no_descent(sid); self.enter_state_no_descent(sid);
if self.terminated { return !enter_path.is_empty(); }
} }
if enter_path.is_empty() { if enter_path.is_empty() {
self.current = Some(target); self.current = Some(target);
false
} else {
true
} }
} }
#[cfg(any(feature = "tokio", feature = "embassy"))] #[cfg(any(feature = "tokio", feature = "embassy"))]
async fn transition(&mut self, _source_matched_state: u16, target: u16) { async fn apply_path(&mut self, from: u16, target: u16) -> bool {
let from = self.current.unwrap_or(target);
let idx = (from as usize) * #n_states + (target as usize); let idx = (from as usize) * #n_states + (target as usize);
let (s, m, e) = __PATH_RANGE[idx]; let (s, m, e) = __PATH_RANGE[idx];
let exit_path = &__PATH_DATA[s as usize..m as usize]; 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 { for &sid in enter_path {
self.enter_state_no_descent(sid).await; self.enter_state_no_descent(sid).await;
if self.terminated { return !enter_path.is_empty(); }
} }
if enter_path.is_empty() { if enter_path.is_empty() {
self.current = Some(target); 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")))] #[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn enter_path(&mut self, root: u16) { fn enter_path(&mut self, root: u16) {
self.enter_state_no_descent(root); self.enter_state_no_descent(root);
self.descend_defaults(root); if self.terminated { return; }
self.follow_defaults();
} }
#[cfg(any(feature = "tokio", feature = "embassy"))] #[cfg(any(feature = "tokio", feature = "embassy"))]
async fn enter_path(&mut self, root: u16) { async fn enter_path(&mut self, root: u16) {
self.enter_state_no_descent(root).await; 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")))] #[cfg(not(any(feature = "tokio", feature = "embassy")))]
@@ -3041,21 +3072,46 @@ pub fn generate(ir: &Ir) -> TokenStream {
#observe_entered #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")))] #[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn descend_defaults(&mut self, sid: u16) { fn follow_defaults(&mut self) {
let mut cur = sid; while let Some(cur) = self.current {
while let Some(child) = __DEFAULT_CHILD[cur as usize] { let target = match __DEFAULT_CHILD[cur as usize] {
self.enter_state_no_descent(child); Some(t) => t,
cur = child; 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"))] #[cfg(any(feature = "tokio", feature = "embassy"))]
async fn descend_defaults(&mut self, sid: u16) { async fn follow_defaults(&mut self) {
let mut cur = sid; while let Some(cur) = self.current {
while let Some(child) = __DEFAULT_CHILD[cur as usize] { let target = match __DEFAULT_CHILD[cur as usize] {
self.enter_state_no_descent(child).await; Some(t) => t,
cur = child; 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 name: Ident,
pub parent: Option<u16>, pub parent: Option<u16>,
pub depth: u8, 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>, 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 entries: Vec<u16>, // action ids
pub exits: Vec<u16>, pub exits: Vec<u16>,
pub handlers: Vec<HandlerIr>, pub handlers: Vec<HandlerIr>,
@@ -192,6 +202,7 @@ fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: S
parent, parent,
depth, depth,
default_child: None, default_child: None,
default_target_ident: None,
entries: Vec::new(), entries: Vec::new(),
exits: Vec::new(), exits: Vec::new(),
handlers: Vec::new(), handlers: Vec::new(),
@@ -451,12 +462,11 @@ fn lower_body(
} }
ir.states[state_id as usize].children = child_ids.clone(); ir.states[state_id as usize].children = child_ids.clone();
if let Some((dc_ident, _)) = &body.default_child { // Stash the unresolved default target. The actual id is filled in by
let resolved = child_ids // `resolve_transitions`, which has the global state-name → id map and
.iter() // can resolve targets that aren't direct children.
.find(|&&cid| ir.states[cid as usize].name == *dc_ident) if let Some((dc_ident, dc_span)) = &body.default_child {
.copied(); ir.states[state_id as usize].default_target_ident = Some((dc_ident.clone(), *dc_span));
ir.states[state_id as usize].default_child = resolved;
} }
Ok(()) 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(()) Ok(())
} }
+76 -34
View File
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
use crate::parse::{StateBody, StatechartInput}; use crate::parse::{StateBody, StatechartInput};
pub fn validate(ir: &Ir) -> syn::Result<()> { pub fn validate(ir: &Ir) -> syn::Result<()> {
validate_default_graph(ir)?;
// Duplicate state names // Duplicate state names
let mut seen = HashMap::new(); let mut seen = HashMap::new();
for s in &ir.states { 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. // `default` is optional: a composite without a `default(...)` is a
if !s.children.is_empty() && s.default_child.is_none() { // valid resting state — transitions targeting it land on the composite
return Err(syn::Error::new( // itself, with no descent. Substates of such a composite are reachable
s.span, // only via explicit transitions. (`default` must still be a direct
format!( // child when declared; that's checked in `validate_parse_tree`.)
"state `{}` has children but no `default` declaration",
s.name
),
));
}
// duplicate transitions per trigger in this state, AND transition // duplicate transitions per trigger in this state, AND transition
// target (if present) must be declared after every action handler // target (if present) must be declared after every action handler
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
Ok(()) 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 /// Additional checks that require the original parse tree (for counting duplicate
/// `default`/`terminate` with their spans, and the "default in childless state" / /// `default`/`terminate` with their spans, and the "terminate in non-root" error).
/// "default not a direct child" / "terminate in non-root" errors).
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> { pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
let root_name = input.name.to_string(); let root_name = input.name.to_string();
validate_body(&input.body, true, &root_name) 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() { // default(...) is a transition that fires immediately after the
return Err(syn::Error::new( // declaring state's entries finish. It may be declared on any state
body.default_span.unwrap(), // (composite or leaf) and may target any state in the chart. A leaf
format!( // with `default(T)` just transitions to T on every entry. The
"`default` in state `{}` which has no child states", // default-graph cycle check in `validate_default_graph` (run from the
state_name // top-level `validate_parse_tree` over the lowered IR) ensures we
), // cannot construct an infinite chain at startup.
));
}
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
),
));
}
}
for c in &body.children { for c in &body.children {
let cname = c.name.to_string(); let cname = c.name.to_string();
validate_body(&c.body, false, &cname)?; 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; use hsmc::statechart;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -9,9 +12,9 @@ statechart! {
M { M {
context: Ctx; context: Ctx;
events: Ev; events: Ev;
default(Leaf); default(A);
state Leaf { state A {
default(Ghost); default(A);
entry: e; 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);
}
+1 -1
View File
@@ -40,7 +40,7 @@ trace-tracing = ["dep:tracing"]
journal = [] journal = []
[dependencies] [dependencies]
hsmc-macros = { path = "../hsmc-macros", version = "0.4.1" } hsmc-macros = { path = "../hsmc-macros", version = "0.5.0" }
heapless = "0.8" heapless = "0.8"
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true } tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
embassy-sync = { version = "0.8", optional = true } embassy-sync = { version = "0.8", optional = true }
+4 -2
View File
@@ -56,8 +56,10 @@ pub enum TransitionReason {
/// fired this transition. /// fired this transition.
Timer { state: u16, timer: u16 }, Timer { state: u16, timer: u16 },
/// Driven from inside the runtime without an originating event or /// Driven from inside the runtime without an originating event or
/// timer (e.g. default-descent into a child after entering a /// timer. Emitted for `default(...)` firings: after a state with a
/// composite state). Reserved; not currently emitted. /// 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, 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")] #[tokio::test(flavor = "current_thread")]
async fn det_descent_target_with_three_default_levels() { 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() tokio::task::LocalSet::new()
.run_until(async { .run_until(async {
let mut m = Desc::new(Ctx); let mut m = Desc::new(Ctx);
@@ -119,9 +121,19 @@ async fn det_descent_target_with_three_default_levels() {
}, },
TraceEvent::EnterBegan { state: SR }, TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { 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 }, TraceEvent::EnterBegan { state: SI },
entry(SI, A_IDLE_IN), entry(SI, A_IDLE_IN),
TraceEvent::Entered { state: SI }, TraceEvent::Entered { state: SI },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SI,
},
TraceEvent::EventReceived { event: E_GO }, TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SI, handler_state: SI,
@@ -138,15 +150,45 @@ async fn det_descent_target_with_three_default_levels() {
TraceEvent::EnterBegan { state: ST }, TraceEvent::EnterBegan { state: ST },
entry(ST, A_TOWER_IN), entry(ST, A_TOWER_IN),
TraceEvent::Entered { state: ST }, TraceEvent::Entered { state: ST },
// Tower's default(L1) fires.
TraceEvent::TransitionFired {
from: Some(ST),
to: SL1,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SL1 }, TraceEvent::EnterBegan { state: SL1 },
entry(SL1, A_L1_IN), entry(SL1, A_L1_IN),
TraceEvent::Entered { state: SL1 }, 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 }, TraceEvent::EnterBegan { state: SL2 },
entry(SL2, A_L2_IN), entry(SL2, A_L2_IN),
TraceEvent::Entered { state: SL2 }, 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 }, TraceEvent::EnterBegan { state: SL3 },
entry(SL3, A_L3_IN), entry(SL3, A_L3_IN),
TraceEvent::Entered { state: SL3 }, TraceEvent::Entered { state: SL3 },
TraceEvent::TransitionComplete {
from: Some(SL2),
to: SL3,
},
TraceEvent::TransitionComplete { TraceEvent::TransitionComplete {
from: Some(SI), from: Some(SI),
to: ST, to: ST,
@@ -164,11 +206,22 @@ async fn det_descent_entries_fire_outer_to_inner() {
.run_until(async { .run_until(async {
let mut m = Desc::new(Ctx); let mut m = Desc::new(Ctx);
let _ = m.dispatch(Ev::Go).await; 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 let split = m
.journal() .journal()
.iter() .iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. })) .position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap(); .unwrap();
let entered: Vec<u16> = m.journal()[split..] let entered: Vec<u16> = m.journal()[split..]
.iter() .iter()
@@ -210,10 +263,21 @@ async fn det_descent_action_kind_sequence() {
.run_until(async { .run_until(async {
let mut m = Desc::new(Ctx); let mut m = Desc::new(Ctx);
let _ = m.dispatch(Ev::Go).await; 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 let split = m
.journal() .journal()
.iter() .iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. })) .position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap(); .unwrap();
// After the Idle exit, all subsequent ActionInvoked must be Entry kind. // After the Idle exit, all subsequent ActionInvoked must be Entry kind.
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..] let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..]
+40 -1
View File
@@ -19,7 +19,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -124,24 +124,63 @@ async fn det_initial_descent_records_full_path() {
let actual = m.take_journal(); let actual = m.take_journal();
let expected = vec![ let expected = vec![
// Initial enter (driven by dispatch's prime-state branch). // 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 { TraceEvent::Started {
chart_hash: Init4::<8>::CHART_HASH, chart_hash: Init4::<8>::CHART_HASH,
}, },
TraceEvent::EnterBegan { state: SR }, TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN), entry(SR, A_R_IN),
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA }, TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SA }, 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 }, TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SB }, 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 }, TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SC }, 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 }, TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SD }, TraceEvent::Entered { state: SD },
TraceEvent::TransitionComplete {
from: Some(SC),
to: SD,
},
// Halt. // Halt.
TraceEvent::EventReceived { event: E_HALT }, TraceEvent::EventReceived { event: E_HALT },
TraceEvent::TerminateRequested { 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> { fn initial_descent() -> Vec<TraceEvent> {
vec![ vec![
TraceEvent::Started { TraceEvent::Started {
@@ -148,12 +149,30 @@ fn initial_descent() -> Vec<TraceEvent> {
}, },
TraceEvent::EnterBegan { state: SR }, TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SP,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SP }, TraceEvent::EnterBegan { state: SP },
entry(SP, A_P_IN), entry(SP, A_P_IN),
TraceEvent::Entered { state: SP }, 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 }, TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SA }, 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 }, TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SD }, 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 }, TraceEvent::EnterBegan { state: SE },
entry(SE, A_E_IN), entry(SE, A_E_IN),
TraceEvent::Entered { state: SE }, TraceEvent::Entered { state: SE },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SE,
},
TraceEvent::TransitionComplete { TraceEvent::TransitionComplete {
from: Some(SA), from: Some(SA),
to: SD, to: SD,
@@ -471,7 +500,15 @@ async fn det_lateral_entry_outer_then_inner() {
let entries: Vec<u16> = m let entries: Vec<u16> = m
.journal() .journal()
.iter() .iter()
.skip_while(|e| !matches!(e, TraceEvent::TransitionFired { .. })) .skip_while(|e| {
!matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.filter_map(|e| match e { .filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state), TraceEvent::Entered { state } => Some(*state),
_ => None, _ => 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"))] #![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)] #![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent}; use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)] #[derive(Default)]
pub struct Ctx; pub struct Ctx;
@@ -293,10 +293,19 @@ async fn det_ord_full_transition_canonical_sequence() {
let mut m = Ord::new(Ctx); let mut m = Ord::new(Ctx);
let _ = m.dispatch(Ev::Go).await; let _ = m.dispatch(Ev::Go).await;
let j = m.take_journal(); 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 let trans_idx = j
.iter() .iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. })) .position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap(); .unwrap();
let kinds: Vec<ActionKind> = j[trans_idx..] let kinds: Vec<ActionKind> = j[trans_idx..]
.iter() .iter()
+33 -2
View File
@@ -104,12 +104,32 @@ async fn det_root_targetable_by_chart_name() {
TraceEvent::EnterBegan { state: SR }, TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN), entry(SR, A_R_IN),
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
// Root's `default(A)` fires.
TraceEvent::TransitionFired {
from: Some(SR),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA }, TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SA }, 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 }, TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SB }, TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
TraceEvent::EventReceived { event: E_UPTOROOT }, TraceEvent::EventReceived { event: E_UPTOROOT },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
handler_state: SB, handler_state: SB,
@@ -148,10 +168,21 @@ async fn det_root_target_does_not_re_enter_root() {
let mut m = Sub::new(Ctx); let mut m = Sub::new(Ctx);
let _ = m.dispatch(Ev::UpToRoot).await; let _ = m.dispatch(Ev::UpToRoot).await;
let j = m.take_journal(); 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 let split = j
.iter() .iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. })) .position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap(); .unwrap();
let entered_after = j[split..] let entered_after = j[split..]
.iter() .iter()
+18
View File
@@ -88,12 +88,30 @@ fn initial_descent() -> Vec<TraceEvent> {
}, },
TraceEvent::EnterBegan { state: SR }, TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SM,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SM }, TraceEvent::EnterBegan { state: SM },
entry(SM, A_M_IN), entry(SM, A_M_IN),
TraceEvent::Entered { state: SM }, 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 }, TraceEvent::EnterBegan { state: SL },
entry(SL, A_L_IN), entry(SL, A_L_IN),
TraceEvent::Entered { state: SL }, TraceEvent::Entered { state: SL },
TraceEvent::TransitionComplete {
from: Some(SM),
to: SL,
},
] ]
} }
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) { 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> { fn initial_descent() -> Vec<TraceEvent> {
// Each `default(...)` fires as a TransitionFired{Internal} /
// TransitionComplete pair around the entered state.
vec![ vec![
TraceEvent::Started { TraceEvent::Started {
chart_hash: Up::<8>::CHART_HASH, chart_hash: Up::<8>::CHART_HASH,
@@ -120,18 +122,54 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::EnterBegan { state: SR }, TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN), entry(SR, A_R_IN),
TraceEvent::Entered { state: SR }, TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA }, TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN), entry(SA, A_A_IN),
TraceEvent::Entered { state: SA }, 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 }, TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN), entry(SB, A_B_IN),
TraceEvent::Entered { state: SB }, 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 }, TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN), entry(SC, A_C_IN),
TraceEvent::Entered { state: SC }, 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 }, TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN), entry(SD, A_D_IN),
TraceEvent::Entered { state: SD }, TraceEvent::Entered { state: SD },
TraceEvent::TransitionComplete {
from: Some(SC),
to: SD,
},
] ]
} }
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) { fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
@@ -361,9 +399,19 @@ async fn det_up_current_state_is_target_after() {
.rev() .rev()
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. })) .find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
.map(|(i, _)| i); .map(|(i, _)| i);
// Find the EVENT-driven UpToB transition (initial defaults
// emit TransitionFired{Internal} too).
let split = j let split = j
.iter() .iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. })) .position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap(); .unwrap();
// The last Entered event must be BEFORE the transition. // The last Entered event must be BEFORE the transition.
assert!( assert!(
+21
View File
@@ -104,6 +104,12 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_ROOT }, 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::EnterBegan { state: ST_IDLE },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_IDLE, state: ST_IDLE,
@@ -111,6 +117,10 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_IDLE }, TraceEvent::Entered { state: ST_IDLE },
TraceEvent::TransitionComplete {
from: Some(ST_ROOT),
to: ST_IDLE,
},
// ── Go event delivered to Idle, transition to Active → Sub ── // ── Go event delivered to Idle, transition to Active → Sub ──
TraceEvent::EventReceived { event: E_GO }, TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered { TraceEvent::EventDelivered {
@@ -136,6 +146,13 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_ACTIVE }, 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::EnterBegan { state: ST_SUB },
TraceEvent::ActionInvoked { TraceEvent::ActionInvoked {
state: ST_SUB, state: ST_SUB,
@@ -143,6 +160,10 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry, kind: ActionKind::Entry,
}, },
TraceEvent::Entered { state: ST_SUB }, TraceEvent::Entered { state: ST_SUB },
TraceEvent::TransitionComplete {
from: Some(ST_ACTIVE),
to: ST_SUB,
},
TraceEvent::TransitionComplete { TraceEvent::TransitionComplete {
from: Some(ST_IDLE), from: Some(ST_IDLE),
to: ST_ACTIVE, to: ST_ACTIVE,