Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb1d10b28
|
||
|
|
3fc0d7c57d
|
No files matched your search
@@ -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
|
||||
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
|
||||
|
||||
### Performance — dispatch hot path
|
||||
|
||||
Generated
+3
-3
@@ -495,7 +495,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hsmc"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"defmt 0.3.100",
|
||||
@@ -515,7 +515,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hsmc-macros"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"hsmc",
|
||||
"proc-macro2",
|
||||
@@ -526,7 +526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hsmc-verification"
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"creusot-std",
|
||||
"hsmc",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["hsmc", "hsmc-macros", "verification"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.1"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = ["Stephen Waits <steve@waits.net>"]
|
||||
|
||||
@@ -28,7 +28,9 @@ statechart! {
|
||||
## What it does
|
||||
|
||||
- **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:
|
||||
`on(PacketRx { rssi: i16 }) => handler;`.
|
||||
- **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
|
||||
inputs, byte-identical journal. The runtime queue and timer table
|
||||
carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge
|
||||
mechanically (`just verify`). 159 tests pin every spec rule
|
||||
(journal sequences + trace-format regression + integration).
|
||||
- **Backward-compatible chart syntax.** Every v0.1 / v0.2 / v0.3
|
||||
statechart compiles under v0.4 unchanged. The 0.4 breaks are in
|
||||
observability surface area only — see
|
||||
[Migration](#migration-from-v03).
|
||||
mechanically (`just verify`). The integration suite pins every
|
||||
spec rule with byte-equal expected-vs-actual journals, trace-format
|
||||
regression checks, and per-feature behavior tests.
|
||||
|
||||
## 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);
|
||||
this section is the on-ramp.
|
||||
|
||||
### 1. The active path — you are in every state from root to leaf
|
||||
### 1. The active path — you are in every state from root to the innermost active state
|
||||
|
||||
At any moment, the machine is in a **path** of states from the root down
|
||||
to one innermost state. If you're in `C` inside `B` inside `A`, you're
|
||||
**simultaneously** in `Root`, `A`, `B`, and `C`. The root is always
|
||||
active until termination.
|
||||
to one innermost active state. If you're in `C` inside `B` inside `A`,
|
||||
you're **simultaneously** in `Root`, `A`, `B`, and `C`. The root is
|
||||
always active until termination.
|
||||
|
||||
The innermost active state is usually a leaf, but it can also be a
|
||||
composite that has children but no `default(...)` — see rule 2.
|
||||
|
||||
This is the most important rule. Every rule below is downstream of it.
|
||||
If a behavior somewhere else seems weird, come back here — it's
|
||||
probably the resolution.
|
||||
|
||||
### 2. Default-descent — composites auto-enter their default child
|
||||
### 2. `default(...)` — a transition that fires immediately on entry
|
||||
|
||||
Every state with children declares one `default(...)`. When a composite
|
||||
state is entered, its entry actions run first, **then** the default
|
||||
child is entered, recursively, until a leaf.
|
||||
Any state **may** declare one `default(...)`. It's a transition — same
|
||||
LCA-aware algorithm as any event-triggered transition (rule 4) — that
|
||||
fires **immediately** after the declaring state's entry actions finish
|
||||
(and its durings start). The target may be **any** state in the chart:
|
||||
a child, a sibling, an ancestor, anywhere. The compiler rejects cycles
|
||||
in the default graph, so the chain is always finite.
|
||||
|
||||
So `Root` with `default(A)` and `A` with `default(B)` means: the
|
||||
machine starts by entering `Root` → `A` → `B`, all on first step.
|
||||
The classic case is a composite descending to a leaf:
|
||||
```
|
||||
state Root { default(A); state A { default(B); state B {} } }
|
||||
```
|
||||
Entering `Root` fires its default → enter `A` → fires its default →
|
||||
enter `B`. The path lands at `B`.
|
||||
|
||||
### 3. Event bubbling — leaf first, first handler wins
|
||||
But because `default` is a real transition, you can also redirect:
|
||||
```
|
||||
state Foyer { default(LivingRoom); } // sibling — entering Foyer
|
||||
state LivingRoom {} // exits Foyer to LivingRoom
|
||||
```
|
||||
Entering `Foyer` fires its default `Foyer → LivingRoom`. LCA = parent of
|
||||
both; Foyer's exits run, LivingRoom's entries run. Foyer was entered
|
||||
and exited as part of one tick.
|
||||
|
||||
If a state has no `default(...)`, nothing happens after its entries —
|
||||
the state itself is the resting innermost active state until something
|
||||
explicitly transitions away. This is the "microwave" pattern: a
|
||||
`Standby` state with sub-modes that the chart enters only on demand.
|
||||
|
||||
### 3. Event bubbling — innermost first, first handler wins
|
||||
|
||||
An event arrives. Search starts at the **innermost** active state. If
|
||||
that state has a handler, it runs and the event is consumed. If not,
|
||||
@@ -95,7 +118,7 @@ with no handler, the event is silently discarded.
|
||||
|
||||
Three corollaries:
|
||||
|
||||
- Leaf shadows ancestor for the same trigger.
|
||||
- Innermost shadows ancestor for the same trigger.
|
||||
- Multiple handlers in one state on the same trigger fire in
|
||||
declaration order; the transition (if any) fires last.
|
||||
- **Timers don't bubble.** A timer belongs to the state that declared
|
||||
@@ -110,7 +133,9 @@ Any transition's algorithm is uniform:
|
||||
The LCA itself is **not** exited.
|
||||
3. Walk **down** from the LCA to target, entering each state. The LCA
|
||||
is **not** re-entered.
|
||||
4. If target has children, default-descend (rule 2).
|
||||
4. If the target declares a `default(...)`, fire it (rule 2). Defaults
|
||||
chain — each link is itself a full transition — until you reach a
|
||||
state with no default. The compiler rejects cycles.
|
||||
|
||||
The LCA always exists because root is always an upper bound. State
|
||||
names are globally unique across the chart, so a transition can target
|
||||
@@ -125,8 +150,9 @@ transition algorithm do nothing. You exit the states strictly between
|
||||
current and target; the target itself is **not** re-entered.
|
||||
|
||||
So from leaf `C` inside `B` inside `A`, an `on(Up) => A;` exits `C`
|
||||
then `B`. `A`'s entry actions don't fire. `A`'s `default` is not
|
||||
followed. `A`'s timers don't restart. `A`'s durings keep running.
|
||||
then `B`. `A`'s entry actions don't fire. `A`'s `default` does not
|
||||
fire (it only fires when `A` is freshly entered, which it wasn't).
|
||||
`A`'s timers don't restart. `A`'s durings keep running.
|
||||
|
||||
You cannot enter a state you never left.
|
||||
|
||||
@@ -136,10 +162,12 @@ Direction is set by the path:
|
||||
|
||||
- **Entries** fire **outer-to-inner**: when entering down through a
|
||||
path, the outermost state's entries fire first, then the next, then
|
||||
the leaf's. Within a single state, entry actions fire in declaration
|
||||
order. After a state's entries finish, its `during:` activities
|
||||
start. Then default-descent (rule 2) applies recursively.
|
||||
- **Exits** fire **inner-to-outer**: leaf first, then parent, then
|
||||
the innermost's. Within a single state, entry actions fire in
|
||||
declaration order. After a state's entries finish, its `during:`
|
||||
activities start. Then, if the state declared a `default(...)`,
|
||||
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
|
||||
order. Before a state's exits begin, its `during:` activities are
|
||||
cancelled.
|
||||
@@ -200,7 +228,7 @@ Add to `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
hsmc = { version = "0.4", features = ["embassy"] } # or ["tokio"]
|
||||
hsmc = { version = "0.5", features = ["embassy"] } # or ["tokio"]
|
||||
```
|
||||
|
||||
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 send(ev)` | Raw push, non-draining. For tests. |
|
||||
| `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. |
|
||||
| `has_pending_events()` | Queue non-empty. |
|
||||
| `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
|
||||
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
|
||||
|
||||
| Example | Purpose |
|
||||
@@ -535,13 +434,14 @@ spec-rule → proof mapping.
|
||||
|
||||
Beyond Creusot, the suite includes:
|
||||
|
||||
- **159 integration tests** — full chart behavior, byte-equal expected-
|
||||
vs-actual journals for ~113 deterministic-flow tests, plus 11 trace-
|
||||
format regression tests pinning every observation verb's exact
|
||||
textual rendering.
|
||||
- **0 missed mutants** under `cargo mutants --features
|
||||
- **Integration tests** — full chart behavior and per-feature
|
||||
determinism, with byte-equal expected-vs-actual journals on the
|
||||
`det_*` deterministic-flow tests, plus trace-format regression
|
||||
tests pinning every observation verb's textual rendering. Run
|
||||
with `cargo test --workspace --features tokio,journal`.
|
||||
- **`cargo mutants`** — full coverage under `cargo mutants --features
|
||||
tokio,journal,trace-log`.
|
||||
- **Miri**: 68 tests, no UB.
|
||||
- **Miri** — clean, no UB on the runtime crates.
|
||||
|
||||
## Observability — one journal, multiple outputs
|
||||
|
||||
@@ -611,25 +511,16 @@ diffed against expected behavior the same way the journal can.
|
||||
|
||||
## Status
|
||||
|
||||
- v0.1 spec: fully implemented.
|
||||
- v0.2: `during:`, optional `events:`, `dispatch()`, `STATE_CHART`,
|
||||
drain-on-send. Up-transitions no longer exit/re-enter the target.
|
||||
- v0.3: `journal` feature + deterministic trace + replay; root
|
||||
state targetable by chart name; Creusot verification of the
|
||||
runtime data structures; 113-test deterministic-flow suite.
|
||||
- v0.4: unified observation pipeline (journal + trace share one
|
||||
vocabulary, fan out to multiple sinks at compile time); logfmt-
|
||||
style trace format with `[statechart:Name]` prefix; transition
|
||||
reasons; entry/exit/transition begin/end markers; multiple trace
|
||||
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.
|
||||
Pre-1.0. Per-release breaking changes are documented in
|
||||
[`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -37,10 +37,16 @@ Each primitive, in roughly the order a developer first meets them.
|
||||
to the target, calling entries along the way.
|
||||
- **Action (without transition)** — a function call attached to a trigger.
|
||||
No state change. That's it.
|
||||
- **Default child** — every non-leaf state declares one default child.
|
||||
Whenever a state with children is entered, the default child is entered
|
||||
immediately after that state's entry actions finish — it's effectively
|
||||
an automatic transition into the child.
|
||||
- **Default transition** — any state **may** declare one `default(...)`.
|
||||
It's a transition (same LCA-aware algorithm as rule 4 / event-driven
|
||||
transitions) that fires immediately after the declaring state's entry
|
||||
actions finish and its durings start. The target may be any state in
|
||||
the chart — a child (the classic descent case), a sibling, an
|
||||
ancestor, anywhere. Defaults chain (each link is itself a transition)
|
||||
until they reach a state with no default. The compiler rejects cycles
|
||||
in the default graph at compile time, so every chain terminates. When
|
||||
a state has no `default`, nothing else happens after entry — the
|
||||
state itself is the resting innermost active state.
|
||||
- **Trigger: event vs. duration** — an event is a variant of your event
|
||||
enum. A duration is a timer (`after Duration` fires once, `every
|
||||
Duration` fires repeatedly). Triggers attach to transitions and to
|
||||
@@ -71,7 +77,8 @@ seems weird, come back to this one — it's probably the resolution.
|
||||
|
||||
`current_state()` returns the innermost active state, which is usually a
|
||||
leaf, but can be a composite (a state with children) if you got there via
|
||||
an up-transition (see below) and haven't moved since.
|
||||
an up-transition (see below) and haven't moved since, or if you
|
||||
transitioned into a composite that has no `default` declared.
|
||||
|
||||
---
|
||||
|
||||
@@ -86,8 +93,9 @@ and still nothing handles it, the event is silently discarded.
|
||||
|
||||
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
|
||||
event. The leaf wins because the search starts there.
|
||||
- A handler on the innermost active state shadows a handler on any
|
||||
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)
|
||||
for the same trigger, the actions fire in declaration order, then the
|
||||
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
|
||||
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
|
||||
no default-descent on root. Use this when you want to wind everything
|
||||
back up to the chart's own scope.
|
||||
no default firing on root (the root wasn't freshly entered). Use this
|
||||
when you want to wind everything back up to the chart's own scope.
|
||||
|
||||
To run a transition:
|
||||
|
||||
@@ -121,8 +129,10 @@ To run a transition:
|
||||
itself is **not** exited.
|
||||
3. Walk **down** from the LCA to the target, entering each state on the
|
||||
way (run entry actions → start durings). The LCA is **not** re-entered.
|
||||
4. If the target has children, follow the default-child chain down to a
|
||||
leaf, entering each as you go.
|
||||
4. If the target declares a `default(...)`, fire it as a recursive
|
||||
transition (using the same algorithm). Defaults chain until a state
|
||||
with no default is reached. Compile-time cycle check guarantees
|
||||
termination.
|
||||
|
||||
The three flavors below are just three relationships between current and
|
||||
target. The same algorithm covers all of them — they only look different
|
||||
@@ -131,8 +141,9 @@ in which steps fire.
|
||||
### Transitioning to a sibling (or anywhere not on your active path)
|
||||
|
||||
The standard case. LCA is somewhere strictly above both current and
|
||||
target. Exit up to LCA, enter down to target, default-descend if the
|
||||
target has children. Already covered above.
|
||||
target. Exit up to LCA, enter down to target, then if the target
|
||||
declares a `default(...)`, fire it as a chained transition. Already
|
||||
covered above.
|
||||
|
||||
### Transitioning to yourself
|
||||
|
||||
@@ -153,11 +164,12 @@ Timers declared on the target restart, because the target itself was
|
||||
exited and re-entered.
|
||||
|
||||
If the target happens to be a composite (it has children, but no child is
|
||||
active right now because you got there via an up-transition), the same
|
||||
default-child rule still applies on re-entry: exit the composite, re-enter
|
||||
it, then descend through its default chain. Self-transitions don't add a
|
||||
new rule — they're just the case where source and target are the same
|
||||
state.
|
||||
active right now because you got there via an up-transition or because
|
||||
the composite has no `default`), the same rule still applies on re-entry:
|
||||
exit the composite, re-enter it, then descend through its default chain
|
||||
if it declares one. A composite without `default` is exited, re-entered,
|
||||
and that's it — no further descent. Self-transitions don't add a new
|
||||
rule — they're just the case where source and target are the same state.
|
||||
|
||||
Personally I don't find self-transitions all that valuable, but people
|
||||
talk about them all the time, so they're supported.
|
||||
@@ -207,7 +219,11 @@ Putting it all together:
|
||||
outermost state's entry actions fire first, then its child's, and so on
|
||||
down to the innermost. Within a single state, entry actions fire in
|
||||
declaration order. After entry actions of a state finish, that state's
|
||||
durings start. Then the default child (if any) is entered, recursively.
|
||||
durings start. Then, if the state declares a `default(...)`, the
|
||||
default fires as a transition (which may exit the state we just
|
||||
entered if the target lies elsewhere in the chart) and the chain
|
||||
continues from there. A state without `default` is the end of the
|
||||
path — control stops there.
|
||||
- **Exit** runs **inner-to-outer**: when leaving a path of states, the
|
||||
innermost state exits first, then its parent, and so on up. Within a
|
||||
single state, exit actions fire in declaration order. Before exit
|
||||
@@ -367,11 +383,13 @@ drafts. Edit freely.)_
|
||||
|
||||
| Scenario | What fires |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Initial machine start | Root entries → default-descend down through each child's entries → durings start at each entered state. |
|
||||
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → default-descend if target has children. |
|
||||
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If innermost has children, default-descend after re-entry. |
|
||||
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. No default-descent. Ancestor's durings and timers continue. |
|
||||
| Transition into a state with children | After the target's entries fire, default-descend through each default child's entries down to a leaf. |
|
||||
| Initial machine start | Root entries → if root declares a `default(...)`, fire it as a transition; chain continues until a state with no default. Durings start at each entered state. |
|
||||
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → if target declares a `default`, fire it; chain continues. |
|
||||
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If the re-entered state declares a `default`, fire it. |
|
||||
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. The ancestor's `default` is **not** fired (it wasn't freshly entered). Durings and timers continue. |
|
||||
| Transition into a state with `default(<descendant>)` | After the target's entries fire, the default fires as a child-direction transition (no exits at this step). The chain continues from the new innermost. |
|
||||
| Transition into a state with `default(<sibling/ancestor>)` | After the target's entries fire, the default fires as a transition that exits the just-entered target and lands elsewhere. The chain continues. |
|
||||
| State has no `default(...)` | After its entries fire, control stops there. The state itself is the innermost active state until something explicitly transitions away. |
|
||||
| Timer fires | The declaring state's handler runs (timers don't bubble). |
|
||||
| Event with no handler | Silently discarded. |
|
||||
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). |
|
||||
|
||||
+124
-68
@@ -100,21 +100,27 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
|
||||
/// in `transition()` with two slice lookups and two straight slice iterations.
|
||||
///
|
||||
/// The (from, to) lookup matches the runtime LCA call: `from` is the active
|
||||
/// leaf (`self.current`), `to` is the transition target. The same three
|
||||
/// transition shapes are encoded uniformly by what the precomputed paths
|
||||
/// contain:
|
||||
/// innermost state (`self.current`), `to` is the transition target. The
|
||||
/// same three transition shapes are encoded uniformly by what the
|
||||
/// precomputed paths contain:
|
||||
///
|
||||
/// - **Up-transition** (`to` is a strict ancestor of `from`): exit_path is
|
||||
/// `from..to` (target excluded; `to` is already on the active path and
|
||||
/// must not be re-entered). enter_path is empty — `transition()` sets
|
||||
/// `self.current = Some(target)` after the loops.
|
||||
/// - **Self-transition** (`from == to`): exit_path is `[to]`. enter_path
|
||||
/// is `[to, default_child(to), default_child(default_child(to)), ...]`
|
||||
/// — re-enter target plus descend defaults.
|
||||
/// is `[to]` — re-enter the target.
|
||||
/// - **Lateral** (LCA is a proper ancestor of both): exit_path walks
|
||||
/// `from` up to but not including LCA. enter_path walks
|
||||
/// LCA-side child of `to` down to `to`, then continues descending
|
||||
/// `__DEFAULT_CHILD` from `to`.
|
||||
/// LCA-side child of `to` down to `to`.
|
||||
///
|
||||
/// Default-descent is **not** folded into these paths. After `transition()`
|
||||
/// applies the precomputed exit/enter, it follows the default chain by
|
||||
/// firing further transitions in a loop (`follow_defaults`). This is
|
||||
/// because `default(...)` may target any state in the chart — sibling,
|
||||
/// ancestor, anywhere — so each step of the chain is itself a full
|
||||
/// LCA-aware transition, not a simple child descent. The compile-time
|
||||
/// default-graph cycle check guarantees the loop terminates.
|
||||
///
|
||||
/// Storage: `__EXIT_DATA` and `__ENTER_DATA` are flat `[u16]` blobs;
|
||||
/// `__EXIT_RANGE[i*N + j]` / `__ENTER_RANGE[i*N + j]` give `(start, end)`
|
||||
@@ -126,7 +132,6 @@ type TransitionPathTables = (Vec<u16>, Vec<u16>, Vec<(u16, u16)>, Vec<(u16, u16)
|
||||
fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
let n = states.len();
|
||||
let parent = |s: u16| -> Option<u16> { states[s as usize].parent };
|
||||
let default_child = |s: u16| -> Option<u16> { states[s as usize].default_child };
|
||||
let depth = |s: u16| -> i32 { states[s as usize].depth as i32 };
|
||||
|
||||
let lca = |mut a: u16, mut b: u16| -> Option<u16> {
|
||||
@@ -171,21 +176,15 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
return (exit_path, Vec::new());
|
||||
}
|
||||
|
||||
// Self-transition: external semantics — exit and re-enter target,
|
||||
// then descend defaults.
|
||||
// Self-transition: external semantics — exit and re-enter target.
|
||||
// Default-following (if any) happens in the runtime chain loop.
|
||||
if from == to {
|
||||
let exit_path = vec![to];
|
||||
let mut enter_path = vec![to];
|
||||
let mut cur = default_child(to);
|
||||
while let Some(c) = cur {
|
||||
enter_path.push(c);
|
||||
cur = default_child(c);
|
||||
}
|
||||
return (exit_path, enter_path);
|
||||
return (vec![to], vec![to]);
|
||||
}
|
||||
|
||||
// Lateral: exit `from` up to (not including) LCA; enter from
|
||||
// LCA-child on `to`'s side down to `to`, then descend defaults.
|
||||
// LCA-child on `to`'s side down to `to`. Default-following
|
||||
// happens in the runtime chain loop.
|
||||
let lca_id = lca(from, to);
|
||||
|
||||
let mut exit_path = Vec::new();
|
||||
@@ -213,13 +212,7 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let mut enter_path: Vec<u16> = rev.into_iter().rev().collect();
|
||||
|
||||
let mut cur = default_child(to);
|
||||
while let Some(c) = cur {
|
||||
enter_path.push(c);
|
||||
cur = default_child(c);
|
||||
}
|
||||
let enter_path: Vec<u16> = rev.into_iter().rev().collect();
|
||||
|
||||
(exit_path, enter_path)
|
||||
};
|
||||
@@ -260,8 +253,8 @@ fn compute_transition_paths(states: &[StateIr]) -> TransitionPathTables {
|
||||
(exit_data, enter_data, exit_range, enter_range)
|
||||
}
|
||||
|
||||
/// Static precompute of `do_terminate()`'s exit walk: for each leaf state,
|
||||
/// the chain `[current, parent(current), ..., __Root]` (root inclusive,
|
||||
/// Static precompute of `do_terminate()`'s exit walk: for each state, the
|
||||
/// chain `[current, parent(current), ..., __Root]` (root inclusive,
|
||||
/// since `__Root` may carry root-level `entry:`/`exit:` actions declared
|
||||
/// at the chart top level — see the `det_initial` test for that pattern).
|
||||
///
|
||||
@@ -376,17 +369,21 @@ fn compute_event_dispatch(ir: &Ir) -> Vec<(u16, Vec<u16>, Option<u16>)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// For each user-declared leaf state that has one or more `during:` activities
|
||||
/// active (either declared on it, on any ancestor, or on root), return its
|
||||
/// state id and the flattened list of durings in leaf-first order. Durings
|
||||
/// declared on the same state follow their declaration order.
|
||||
/// For each state that can be the innermost active state, return its state id
|
||||
/// and the flattened list of `during:` activities active there (declared on it
|
||||
/// or on any ancestor) in leaf-first order. Durings declared on the same state
|
||||
/// follow their declaration order.
|
||||
///
|
||||
/// Leaf states with no active durings are omitted — the run loop uses its
|
||||
/// default (channel+timer) path for those.
|
||||
fn active_durings_per_leaf(ir: &Ir) -> Vec<(u16, Vec<During>)> {
|
||||
/// A state can be innermost iff it has no children, OR it has children but no
|
||||
/// `default` child (so default-descent stops at it). Composites with a default
|
||||
/// child are always descended past at runtime, so they're skipped here.
|
||||
///
|
||||
/// Innermost-able states with no active durings are omitted — the run loop
|
||||
/// uses its default (channel+timer) path for those.
|
||||
fn active_durings_per_innermost_state(ir: &Ir) -> Vec<(u16, Vec<During>)> {
|
||||
let mut out = Vec::new();
|
||||
for s in &ir.states {
|
||||
if !s.children.is_empty() {
|
||||
if !s.children.is_empty() && s.default_child.is_some() {
|
||||
continue;
|
||||
}
|
||||
if s.parent.is_none() {
|
||||
@@ -425,14 +422,14 @@ fn emit_during_bindings(chain: &[During]) -> (Vec<Ident>, TokenStream) {
|
||||
(idents, quote! { #(#stmts)* })
|
||||
}
|
||||
|
||||
/// Emit the per-leaf match arms for the tokio run loop. Each arm constructs
|
||||
/// the active during: futures (split-borrowing context fields), then races
|
||||
/// them against the event channel and the next-timer sleep via
|
||||
/// `tokio::select!`. The default arm — used for leaves with no durings — is
|
||||
/// emitted by the caller.
|
||||
/// Emit the per-state match arms for the tokio run loop, keyed on the
|
||||
/// innermost active state id. Each arm constructs the active during: futures
|
||||
/// (split-borrowing context fields), then races them against the event channel
|
||||
/// and the next-timer sleep via `tokio::select!`. The default arm — used for
|
||||
/// states with no active durings — is emitted by the caller.
|
||||
fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let (binds, stmts) = emit_during_bindings(&chain);
|
||||
let select_arms = binds.iter().map(|b| {
|
||||
quote! { ev = #b => __HsmcRace::Event(ev), }
|
||||
@@ -462,7 +459,7 @@ fn emit_tokio_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
/// (no `events:` declared, no channel). The default arm races only the timer.
|
||||
fn emit_tokio_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let (binds, stmts) = emit_during_bindings(&chain);
|
||||
let select_arms = binds.iter().map(|b| {
|
||||
quote! { ev = #b => __HsmcRace::Event(ev), }
|
||||
@@ -639,7 +636,7 @@ fn embassy_select_for_arity(arity: usize) -> (Ident, Vec<Ident>) {
|
||||
/// compiles unchanged.
|
||||
fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let n = chain.len();
|
||||
if n + 2 > 6 {
|
||||
let msg = format!(
|
||||
@@ -703,7 +700,7 @@ fn emit_embassy_race_arms_with_channel(ir: &Ir) -> Vec<TokenStream> {
|
||||
/// durings on a timer-only path.
|
||||
fn emit_embassy_race_arms_timer_only(ir: &Ir) -> Vec<TokenStream> {
|
||||
let mut arms = Vec::new();
|
||||
for (sid, chain) in active_durings_per_leaf(ir) {
|
||||
for (sid, chain) in active_durings_per_innermost_state(ir) {
|
||||
let n = chain.len();
|
||||
if n == 0 {
|
||||
continue; // falls through to default (just timer)
|
||||
@@ -1104,8 +1101,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
__timer_reason_str(__t_state, __t_trigger)
|
||||
);
|
||||
};
|
||||
#[allow(dead_code)]
|
||||
let _observe_transition_fired_internal = quote! {
|
||||
let observe_transition_fired_internal = quote! {
|
||||
::hsmc::__chart_observe!(
|
||||
TransitionFired,
|
||||
&mut self.__journal,
|
||||
@@ -1890,7 +1886,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
// run() bodies to decide whether to exit when no timers are pending: if
|
||||
// the current state has an active during, the run loop should keep
|
||||
// awaiting rather than break (the during might yet produce an event).
|
||||
let states_with_active_durings: Vec<u16> = active_durings_per_leaf(ir)
|
||||
let states_with_active_durings: Vec<u16> = active_durings_per_innermost_state(ir)
|
||||
.into_iter()
|
||||
.map(|(sid, _)| sid)
|
||||
.collect();
|
||||
@@ -2595,7 +2591,7 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
/// Returns `true` once the machine has processed its `terminate` event (§4.4).
|
||||
pub fn is_terminated(&self) -> bool { self.terminated }
|
||||
|
||||
/// Return the current leaf state. Panics if called before the
|
||||
/// Return the innermost active state. Panics if called before the
|
||||
/// first `step()` (§4.4).
|
||||
pub fn current_state(&self) -> #state_enum_name {
|
||||
__state_id_to_public(self.current.expect("machine not started; call step() first"))
|
||||
@@ -2950,12 +2946,18 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
// An empty enter slice (`m == e`) means up-transition: target
|
||||
// is already on the active path, must not be re-entered, and
|
||||
// `self.current` is set to `target` after the exit loop. A
|
||||
// non-empty enter slice ends at the deepest default-descended
|
||||
// leaf; `enter_state_no_descent` sets `self.current` as it
|
||||
// goes, so no fixup is needed on that path.
|
||||
// non-empty enter slice ends at the target itself —
|
||||
// `enter_state_no_descent` sets `self.current` as it goes.
|
||||
// Default-following runs after the path apply via
|
||||
// `follow_defaults`, which fires further transitions for any
|
||||
// chained `default(...)` declarations on the just-entered
|
||||
// state. The compile-time default-graph cycle check
|
||||
// guarantees the chain terminates.
|
||||
// Returns true if any state was newly entered (i.e., the
|
||||
// enter_path was non-empty). False on an up-transition, where
|
||||
// the target was already on the active path.
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
fn apply_path(&mut self, from: u16, target: u16) -> bool {
|
||||
let idx = (from as usize) * #n_states + (target as usize);
|
||||
let (s, m, e) = __PATH_RANGE[idx];
|
||||
let exit_path = &__PATH_DATA[s as usize..m as usize];
|
||||
@@ -2965,15 +2967,18 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
}
|
||||
for &sid in enter_path {
|
||||
self.enter_state_no_descent(sid);
|
||||
if self.terminated { return !enter_path.is_empty(); }
|
||||
}
|
||||
if enter_path.is_empty() {
|
||||
self.current = Some(target);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
async fn apply_path(&mut self, from: u16, target: u16) -> bool {
|
||||
let idx = (from as usize) * #n_states + (target as usize);
|
||||
let (s, m, e) = __PATH_RANGE[idx];
|
||||
let exit_path = &__PATH_DATA[s as usize..m as usize];
|
||||
@@ -2983,22 +2988,48 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
}
|
||||
for &sid in enter_path {
|
||||
self.enter_state_no_descent(sid).await;
|
||||
if self.terminated { return !enter_path.is_empty(); }
|
||||
}
|
||||
if enter_path.is_empty() {
|
||||
self.current = Some(target);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
let entered = self.apply_path(from, target);
|
||||
if self.terminated { return; }
|
||||
if entered {
|
||||
self.follow_defaults();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
|
||||
let from = self.current.unwrap_or(target);
|
||||
let entered = self.apply_path(from, target).await;
|
||||
if self.terminated { return; }
|
||||
if entered {
|
||||
self.follow_defaults().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn enter_path(&mut self, root: u16) {
|
||||
self.enter_state_no_descent(root);
|
||||
self.descend_defaults(root);
|
||||
if self.terminated { return; }
|
||||
self.follow_defaults();
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn enter_path(&mut self, root: u16) {
|
||||
self.enter_state_no_descent(root).await;
|
||||
self.descend_defaults(root).await;
|
||||
if self.terminated { return; }
|
||||
self.follow_defaults().await;
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
@@ -3041,21 +3072,46 @@ pub fn generate(ir: &Ir) -> TokenStream {
|
||||
#observe_entered
|
||||
}
|
||||
|
||||
// After a state's entries finish (initial entry or any
|
||||
// transition), follow its `default(...)` chain by firing
|
||||
// further transitions. Each step is a full LCA-aware
|
||||
// transition because the default target may be a sibling,
|
||||
// ancestor, or anywhere else — not just a direct child. The
|
||||
// compile-time default-graph cycle check guarantees the loop
|
||||
// terminates.
|
||||
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
|
||||
fn descend_defaults(&mut self, sid: u16) {
|
||||
let mut cur = sid;
|
||||
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
|
||||
self.enter_state_no_descent(child);
|
||||
cur = child;
|
||||
fn follow_defaults(&mut self) {
|
||||
while let Some(cur) = self.current {
|
||||
let target = match __DEFAULT_CHILD[cur as usize] {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
};
|
||||
#observe_transition_fired_internal
|
||||
let __t_complete_from = self.current;
|
||||
let entered = self.apply_path(cur, target);
|
||||
#observe_transition_complete
|
||||
if self.terminated { return; }
|
||||
// If the default fired an up-transition (no new state
|
||||
// entered), don't keep chaining — the ancestor we
|
||||
// landed on was already on the active path; its
|
||||
// default was not "freshly entered" and must not fire.
|
||||
if !entered { return; }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "tokio", feature = "embassy"))]
|
||||
async fn descend_defaults(&mut self, sid: u16) {
|
||||
let mut cur = sid;
|
||||
while let Some(child) = __DEFAULT_CHILD[cur as usize] {
|
||||
self.enter_state_no_descent(child).await;
|
||||
cur = child;
|
||||
async fn follow_defaults(&mut self) {
|
||||
while let Some(cur) = self.current {
|
||||
let target = match __DEFAULT_CHILD[cur as usize] {
|
||||
Some(t) => t,
|
||||
None => return,
|
||||
};
|
||||
#observe_transition_fired_internal
|
||||
let __t_complete_from = self.current;
|
||||
let entered = self.apply_path(cur, target).await;
|
||||
#observe_transition_complete
|
||||
if self.terminated { return; }
|
||||
if !entered { return; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-6
@@ -79,7 +79,17 @@ pub struct StateIr {
|
||||
pub name: Ident,
|
||||
pub parent: Option<u16>,
|
||||
pub depth: u8,
|
||||
/// Target of this state's `default(...)` declaration, if any. Resolved
|
||||
/// in `resolve_transitions`. The target may be any state in the chart
|
||||
/// (not just a direct child) — `default(...)` is a transition that
|
||||
/// fires immediately after the declaring state's entries, using the
|
||||
/// same LCA-aware algorithm as event-driven transitions. The
|
||||
/// default-graph cycle check in `validate.rs` ensures the chain
|
||||
/// terminates.
|
||||
pub default_child: Option<u16>,
|
||||
/// Original ident from the parse tree, kept until resolution so we can
|
||||
/// emit good error spans on unknown targets / cycles.
|
||||
pub default_target_ident: Option<(Ident, Span)>,
|
||||
pub entries: Vec<u16>, // action ids
|
||||
pub exits: Vec<u16>,
|
||||
pub handlers: Vec<HandlerIr>,
|
||||
@@ -192,6 +202,7 @@ fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: S
|
||||
parent,
|
||||
depth,
|
||||
default_child: None,
|
||||
default_target_ident: None,
|
||||
entries: Vec::new(),
|
||||
exits: Vec::new(),
|
||||
handlers: Vec::new(),
|
||||
@@ -451,12 +462,11 @@ fn lower_body(
|
||||
}
|
||||
ir.states[state_id as usize].children = child_ids.clone();
|
||||
|
||||
if let Some((dc_ident, _)) = &body.default_child {
|
||||
let resolved = child_ids
|
||||
.iter()
|
||||
.find(|&&cid| ir.states[cid as usize].name == *dc_ident)
|
||||
.copied();
|
||||
ir.states[state_id as usize].default_child = resolved;
|
||||
// Stash the unresolved default target. The actual id is filled in by
|
||||
// `resolve_transitions`, which has the global state-name → id map and
|
||||
// can resolve targets that aren't direct children.
|
||||
if let Some((dc_ident, dc_span)) = &body.default_child {
|
||||
ir.states[state_id as usize].default_target_ident = Some((dc_ident.clone(), *dc_span));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -547,5 +557,24 @@ pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve default-transition targets against the same global map.
|
||||
for i in 0..n {
|
||||
if let Some((ident, _span)) = ir.states[i].default_target_ident.clone() {
|
||||
let tname = ident.to_string();
|
||||
match name_map.get(&tname).copied() {
|
||||
Some(id) => {
|
||||
ir.states[i].default_child = Some(id);
|
||||
}
|
||||
None => {
|
||||
return Err(syn::Error::new(
|
||||
ident.span(),
|
||||
format!("unknown state `{}` in `default(...)`", ident),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+76
-34
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
|
||||
use crate::parse::{StateBody, StatechartInput};
|
||||
|
||||
pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
validate_default_graph(ir)?;
|
||||
// Duplicate state names
|
||||
let mut seen = HashMap::new();
|
||||
for s in &ir.states {
|
||||
@@ -94,16 +95,11 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Children require default; default must be a direct child; no default allowed if no children.
|
||||
if !s.children.is_empty() && s.default_child.is_none() {
|
||||
return Err(syn::Error::new(
|
||||
s.span,
|
||||
format!(
|
||||
"state `{}` has children but no `default` declaration",
|
||||
s.name
|
||||
),
|
||||
));
|
||||
}
|
||||
// `default` is optional: a composite without a `default(...)` is a
|
||||
// valid resting state — transitions targeting it land on the composite
|
||||
// itself, with no descent. Substates of such a composite are reachable
|
||||
// only via explicit transitions. (`default` must still be a direct
|
||||
// child when declared; that's checked in `validate_parse_tree`.)
|
||||
|
||||
// duplicate transitions per trigger in this state, AND transition
|
||||
// target (if present) must be declared after every action handler
|
||||
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk the default-edge graph and reject cycles. Each state has at most one
|
||||
/// outgoing default edge, so the graph has out-degree ≤ 1 and any cycle is a
|
||||
/// simple chain that loops. Detection: 3-color DFS via an iterative chain walk.
|
||||
///
|
||||
/// Cycles are rejected at compile time because at runtime, a default chain
|
||||
/// fires as a sequence of immediate transitions on entry — a cycle would loop
|
||||
/// forever, never returning control to the dispatcher.
|
||||
fn validate_default_graph(ir: &Ir) -> syn::Result<()> {
|
||||
let n = ir.states.len();
|
||||
// 0 = white (unvisited), 1 = gray (on current chain), 2 = black (done).
|
||||
let mut color = vec![0u8; n];
|
||||
for start in 0..n {
|
||||
if color[start] != 0 {
|
||||
continue;
|
||||
}
|
||||
let mut chain: Vec<usize> = Vec::new();
|
||||
let mut cur = start;
|
||||
loop {
|
||||
if color[cur] == 2 {
|
||||
// Joined a previously-cleared subgraph. Chain so far is fine.
|
||||
break;
|
||||
}
|
||||
if color[cur] == 1 {
|
||||
// Back-edge into the current chain — cycle.
|
||||
let cycle_start = chain
|
||||
.iter()
|
||||
.position(|&x| x == cur)
|
||||
.expect("gray node must be on current chain");
|
||||
let cycle: Vec<String> = chain[cycle_start..]
|
||||
.iter()
|
||||
.map(|&id| ir.states[id].name.to_string())
|
||||
.collect();
|
||||
let pretty = format!("{} -> {}", cycle.join(" -> "), cycle[0]);
|
||||
let err_span = ir.states[chain[cycle_start]]
|
||||
.default_target_ident
|
||||
.as_ref()
|
||||
.map(|(_, s)| *s)
|
||||
.unwrap_or(ir.states[chain[cycle_start]].span);
|
||||
return Err(syn::Error::new(
|
||||
err_span,
|
||||
format!(
|
||||
"default-transition cycle detected: {} \
|
||||
(entering any state in this cycle would loop forever \
|
||||
before reaching user code)",
|
||||
pretty
|
||||
),
|
||||
));
|
||||
}
|
||||
color[cur] = 1;
|
||||
chain.push(cur);
|
||||
match ir.states[cur].default_child {
|
||||
Some(next) => cur = next as usize,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
for &id in &chain {
|
||||
color[id] = 2;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Additional checks that require the original parse tree (for counting duplicate
|
||||
/// `default`/`terminate` with their spans, and the "default in childless state" /
|
||||
/// "default not a direct child" / "terminate in non-root" errors).
|
||||
/// `default`/`terminate` with their spans, and the "terminate in non-root" error).
|
||||
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
|
||||
let root_name = input.name.to_string();
|
||||
validate_body(&input.body, true, &root_name)
|
||||
@@ -217,28 +274,13 @@ fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Resu
|
||||
));
|
||||
}
|
||||
}
|
||||
if body.default_child.is_some() && body.children.is_empty() {
|
||||
return Err(syn::Error::new(
|
||||
body.default_span.unwrap(),
|
||||
format!(
|
||||
"`default` in state `{}` which has no child states",
|
||||
state_name
|
||||
),
|
||||
));
|
||||
}
|
||||
if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) {
|
||||
// default must be a direct child
|
||||
let is_child = body.children.iter().any(|c| c.name == *dc);
|
||||
if !is_child {
|
||||
return Err(syn::Error::new(
|
||||
*dspan,
|
||||
format!(
|
||||
"`default({})` in state `{}`: `{}` is not a direct child of `{}`",
|
||||
dc, state_name, dc, state_name
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// default(...) is a transition that fires immediately after the
|
||||
// declaring state's entries finish. It may be declared on any state
|
||||
// (composite or leaf) and may target any state in the chart. A leaf
|
||||
// with `default(T)` just transitions to T on every entry. The
|
||||
// default-graph cycle check in `validate_default_graph` (run from the
|
||||
// top-level `validate_parse_tree` over the lowered IR) ensures we
|
||||
// cannot construct an infinite chain at startup.
|
||||
for c in &body.children {
|
||||
let cname = c.name.to_string();
|
||||
validate_body(&c.body, false, &cname)?;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { _U }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Parent);
|
||||
state Parent {
|
||||
state ChildA { entry: e; }
|
||||
state ChildB { entry: e; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn e(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: state `Parent` has children but no `default` declaration
|
||||
--> tests/ui/t9_2_missing_default.rs:13:11
|
||||
|
|
||||
13 | state Parent {
|
||||
| ^^^^^^
|
||||
@@ -1,25 +0,0 @@
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { _U }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Parent);
|
||||
state Parent {
|
||||
default(Cousin);
|
||||
state Child { entry: e; }
|
||||
}
|
||||
state Cousin { entry: e; }
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn e(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -1,5 +0,0 @@
|
||||
error: `default(Cousin)` in state `Parent`: `Cousin` is not a direct child of `Parent`
|
||||
--> tests/ui/t9_4_default_non_child.rs:14:9
|
||||
|
|
||||
14 | default(Cousin);
|
||||
| ^^^^^^^
|
||||
+6
-3
@@ -1,3 +1,6 @@
|
||||
// `default(self)` is a 1-cycle: entering A would fire its default
|
||||
// transition to A, which would fire its default again, ad infinitum.
|
||||
// Compile-time error.
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -9,9 +12,9 @@ statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Leaf);
|
||||
state Leaf {
|
||||
default(Ghost);
|
||||
default(A);
|
||||
state A {
|
||||
default(A);
|
||||
entry: e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: default-transition cycle detected: A -> A (entering any state in this cycle would loop forever before reaching user code)
|
||||
--> tests/ui/t9_4_default_self_cycle.rs:17:9
|
||||
|
|
||||
17 | default(A);
|
||||
| ^^^^^^^
|
||||
@@ -1,5 +0,0 @@
|
||||
error: `default` in state `Leaf` which has no child states
|
||||
--> tests/ui/t9_5_default_childless.rs:14:9
|
||||
|
|
||||
14 | default(Ghost);
|
||||
| ^^^^^^^
|
||||
@@ -0,0 +1,30 @@
|
||||
// A → B → A is a default cycle: entering either state would loop
|
||||
// forever before reaching user code. Compile-time error.
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { _U }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(A);
|
||||
state A {
|
||||
default(B);
|
||||
entry: e;
|
||||
}
|
||||
state B {
|
||||
default(A);
|
||||
entry: e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn e(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
error: default-transition cycle detected: A -> B -> A (entering any state in this cycle would loop forever before reaching user code)
|
||||
--> tests/ui/t9_5_default_two_cycle.rs:16:9
|
||||
|
|
||||
16 | default(B);
|
||||
| ^^^^^^^
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Compile-pass: a composite state may omit `default(...)`.
|
||||
//!
|
||||
//! When a composite has children but no `default`, transitions targeting
|
||||
//! that composite land on the composite itself — the substates are reachable
|
||||
//! only via explicit transitions. Microwave-style chart: `Standby` is the
|
||||
//! resting state and `LowPower` is a sub-mode you enter only on demand.
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
PowerOn,
|
||||
PowerOff,
|
||||
EnterLowPower,
|
||||
LeaveLowPower,
|
||||
Halt,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
Microwave {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Standby);
|
||||
terminate(Halt);
|
||||
|
||||
state Standby {
|
||||
// No `default(...)` — Standby is itself the resting state.
|
||||
on(PowerOn) => On;
|
||||
on(EnterLowPower) => LowPower;
|
||||
|
||||
state LowPower {
|
||||
on(LeaveLowPower) => Standby;
|
||||
}
|
||||
}
|
||||
|
||||
state On {
|
||||
on(PowerOff) => Standby;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MicrowaveActions for MicrowaveActionContext<'_> {}
|
||||
|
||||
fn main() {
|
||||
let _ = Microwave::new(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Compile-pass: a state's `default(...)` may target an ancestor. Entering
|
||||
//! the declaring state would immediately exit back up to the ancestor (the
|
||||
//! up-transition rule applies — ancestor not re-entered). The state is
|
||||
//! reached only via an explicit transition; without one, control never
|
||||
//! enters it. (No cycle here: Outer has no default; Inner targets Outer.)
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { GoIn, Halt }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Outer);
|
||||
terminate(Halt);
|
||||
|
||||
state Outer {
|
||||
entry: outer_in;
|
||||
on(GoIn) => Inner;
|
||||
state Inner {
|
||||
entry: inner_in;
|
||||
// Default jumps up to Outer. Inner is reachable only via the
|
||||
// explicit GoIn transition; once entered, this default fires
|
||||
// and lands the chart back at Outer (no re-entry of Outer).
|
||||
default(Outer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn outer_in(&mut self) {}
|
||||
fn inner_in(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let _ = M::new(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Compile-pass: a state's `default(...)` may target a sibling. Entering
|
||||
//! the declaring state immediately fires a transition out to the sibling,
|
||||
//! using the same LCA-aware algorithm as any other transition.
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::statechart;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev { Halt }
|
||||
|
||||
pub struct Ctx;
|
||||
|
||||
statechart! {
|
||||
M {
|
||||
context: Ctx;
|
||||
events: Ev;
|
||||
default(Foyer);
|
||||
terminate(Halt);
|
||||
|
||||
state Foyer {
|
||||
// Default redirects out of Foyer to its sibling LivingRoom.
|
||||
default(LivingRoom);
|
||||
entry: foyer_in;
|
||||
}
|
||||
state LivingRoom {
|
||||
entry: living_in;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MActions for MActionContext<'_> {
|
||||
fn foyer_in(&mut self) {}
|
||||
fn living_in(&mut self) {}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let _ = M::new(Ctx);
|
||||
}
|
||||
+1
-1
@@ -40,7 +40,7 @@ trace-tracing = ["dep:tracing"]
|
||||
journal = []
|
||||
|
||||
[dependencies]
|
||||
hsmc-macros = { path = "../hsmc-macros", version = "0.4.1" }
|
||||
hsmc-macros = { path = "../hsmc-macros", version = "0.5.0" }
|
||||
heapless = "0.8"
|
||||
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
|
||||
embassy-sync = { version = "0.8", optional = true }
|
||||
|
||||
+4
-2
@@ -56,8 +56,10 @@ pub enum TransitionReason {
|
||||
/// fired this transition.
|
||||
Timer { state: u16, timer: u16 },
|
||||
/// Driven from inside the runtime without an originating event or
|
||||
/// timer (e.g. default-descent into a child after entering a
|
||||
/// composite state). Reserved; not currently emitted.
|
||||
/// timer. Emitted for `default(...)` firings: after a state with a
|
||||
/// declared default is entered, the default fires as a transition
|
||||
/// with this reason. The default target may be any state in the
|
||||
/// chart — sibling, ancestor, anywhere.
|
||||
Internal,
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -106,7 +106,9 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_descent_target_with_three_default_levels() {
|
||||
// Idle → Tower: Tower has default chain L1 → L2 → L3.
|
||||
// Idle → Tower: Tower's `default(L1)` fires as a transition, L1's
|
||||
// `default(L2)` fires, L2's `default(L3)` fires. Each link is a
|
||||
// real transition with its own TransitionFired/Complete pair.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
@@ -119,9 +121,19 @@ async fn det_descent_target_with_three_default_levels() {
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
TraceEvent::Entered { state: SR },
|
||||
// Root → Idle fires as Root's default.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SI,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SI },
|
||||
entry(SI, A_IDLE_IN),
|
||||
TraceEvent::Entered { state: SI },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SI,
|
||||
},
|
||||
TraceEvent::EventReceived { event: E_GO },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SI,
|
||||
@@ -138,15 +150,45 @@ async fn det_descent_target_with_three_default_levels() {
|
||||
TraceEvent::EnterBegan { state: ST },
|
||||
entry(ST, A_TOWER_IN),
|
||||
TraceEvent::Entered { state: ST },
|
||||
// Tower's default(L1) fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST),
|
||||
to: SL1,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL1 },
|
||||
entry(SL1, A_L1_IN),
|
||||
TraceEvent::Entered { state: SL1 },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST),
|
||||
to: SL1,
|
||||
},
|
||||
// L1's default(L2) fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL1),
|
||||
to: SL2,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL2 },
|
||||
entry(SL2, A_L2_IN),
|
||||
TraceEvent::Entered { state: SL2 },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SL1),
|
||||
to: SL2,
|
||||
},
|
||||
// L2's default(L3) fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL2),
|
||||
to: SL3,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL3 },
|
||||
entry(SL3, A_L3_IN),
|
||||
TraceEvent::Entered { state: SL3 },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SL2),
|
||||
to: SL3,
|
||||
},
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SI),
|
||||
to: ST,
|
||||
@@ -164,11 +206,22 @@ async fn det_descent_entries_fire_outer_to_inner() {
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
// After the GO transition: order of Entered events should be Tower → L1 → L2 → L3.
|
||||
// After the GO event-driven transition: order of Entered
|
||||
// events should be Tower → L1 → L2 → L3. We split on the
|
||||
// event-driven TransitionFired specifically (initial-entry
|
||||
// defaults emit TransitionFired{Internal} too).
|
||||
let split = m
|
||||
.journal()
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let entered: Vec<u16> = m.journal()[split..]
|
||||
.iter()
|
||||
@@ -210,10 +263,21 @@ async fn det_descent_action_kind_sequence() {
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
// Split on the event-driven TransitionFired (the initial
|
||||
// default-fire is also TransitionFired{Internal} but happens
|
||||
// before GO).
|
||||
let split = m
|
||||
.journal()
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
// After the Idle exit, all subsequent ActionInvoked must be Entry kind.
|
||||
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..]
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -124,24 +124,63 @@ async fn det_initial_descent_records_full_path() {
|
||||
let actual = m.take_journal();
|
||||
let expected = vec![
|
||||
// Initial enter (driven by dispatch's prime-state branch).
|
||||
// Each `default(...)` is a real transition, so the journal
|
||||
// brackets every default-fire with TransitionFired
|
||||
// (Internal) / TransitionComplete pairs.
|
||||
TraceEvent::Started {
|
||||
chart_hash: Init4::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
},
|
||||
// Halt.
|
||||
TraceEvent::EventReceived { event: E_HALT },
|
||||
TraceEvent::TerminateRequested { event: E_HALT },
|
||||
|
||||
@@ -140,7 +140,8 @@ fn handler(state: u16, action: u16) -> TraceEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// Initial descent: Root → Parent → A.
|
||||
// Initial descent: Root → (Root's default fires) → Parent → (Parent's
|
||||
// default fires) → A. Each default-fire is a real transition.
|
||||
fn initial_descent() -> Vec<TraceEvent> {
|
||||
vec![
|
||||
TraceEvent::Started {
|
||||
@@ -148,12 +149,30 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SP,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SP },
|
||||
entry(SP, A_P_IN),
|
||||
TraceEvent::Entered { state: SP },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SP,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SP),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SP),
|
||||
to: SA,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -285,9 +304,19 @@ async fn det_lateral_target_with_children_default_descends() {
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
// D's `default(E)` fires as an internal transition.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SD),
|
||||
to: SE,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SE },
|
||||
entry(SE, A_E_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SD),
|
||||
to: SE,
|
||||
},
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SD,
|
||||
@@ -471,7 +500,15 @@ async fn det_lateral_entry_outer_then_inner() {
|
||||
let entries: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.skip_while(|e| !matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.skip_while(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -293,10 +293,19 @@ async fn det_ord_full_transition_canonical_sequence() {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let j = m.take_journal();
|
||||
// Find the TransitionFired and capture everything until A's exit and B's entry are done.
|
||||
// Find the EVENT-driven TransitionFired (initial default-fires
|
||||
// are TransitionFired{Internal}; we want the GO transition).
|
||||
let trans_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let kinds: Vec<ActionKind> = j[trans_idx..]
|
||||
.iter()
|
||||
|
||||
@@ -104,12 +104,32 @@ async fn det_root_targetable_by_chart_name() {
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SR },
|
||||
// Root's `default(A)` fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
},
|
||||
// A's `default(B)` fires.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
TraceEvent::EventReceived { event: E_UPTOROOT },
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SB,
|
||||
@@ -148,10 +168,21 @@ async fn det_root_target_does_not_re_enter_root() {
|
||||
let mut m = Sub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||
let j = m.take_journal();
|
||||
// After the TransitionFired, no Entered event should fire.
|
||||
// After the EVENT-driven TransitionFired (the UpToRoot one),
|
||||
// no Entered event should fire — the up-transition exits B
|
||||
// and A but never re-enters root, and root has no default
|
||||
// (so no internal default-fire follows either).
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let entered_after = j[split..]
|
||||
.iter()
|
||||
|
||||
@@ -88,12 +88,30 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SM,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SM },
|
||||
entry(SM, A_M_IN),
|
||||
TraceEvent::Entered { state: SM },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SM,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SM),
|
||||
to: SL,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SL },
|
||||
entry(SL, A_L_IN),
|
||||
TraceEvent::Entered { state: SL },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SM),
|
||||
to: SL,
|
||||
},
|
||||
]
|
||||
}
|
||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||
|
||||
+49
-1
@@ -113,6 +113,8 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
}
|
||||
}
|
||||
fn initial_descent() -> Vec<TraceEvent> {
|
||||
// Each `default(...)` fires as a TransitionFired{Internal} /
|
||||
// TransitionComplete pair around the entered state.
|
||||
vec![
|
||||
TraceEvent::Started {
|
||||
chart_hash: Up::<8>::CHART_HASH,
|
||||
@@ -120,18 +122,54 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
TraceEvent::EnterBegan { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SR),
|
||||
to: SA,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(SC),
|
||||
to: SD,
|
||||
},
|
||||
]
|
||||
}
|
||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||
@@ -361,9 +399,19 @@ async fn det_up_current_state_is_target_after() {
|
||||
.rev()
|
||||
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
|
||||
.map(|(i, _)| i);
|
||||
// Find the EVENT-driven UpToB transition (initial defaults
|
||||
// emit TransitionFired{Internal} too).
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
reason: TransitionReason::Event { .. },
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
// The last Entered event must be BEFORE the transition.
|
||||
assert!(
|
||||
|
||||
@@ -104,6 +104,12 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_ROOT },
|
||||
// Root's `default(Idle)` fires as an internal transition.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST_ROOT),
|
||||
to: ST_IDLE,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: ST_IDLE },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_IDLE,
|
||||
@@ -111,6 +117,10 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_IDLE },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST_ROOT),
|
||||
to: ST_IDLE,
|
||||
},
|
||||
// ── Go event delivered to Idle, transition to Active → Sub ──
|
||||
TraceEvent::EventReceived { event: E_GO },
|
||||
TraceEvent::EventDelivered {
|
||||
@@ -136,6 +146,13 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_ACTIVE },
|
||||
// Active's `default(Sub)` fires as an internal transition,
|
||||
// bracketed by its own TransitionFired/Complete.
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST_ACTIVE),
|
||||
to: ST_SUB,
|
||||
reason: TransitionReason::Internal,
|
||||
},
|
||||
TraceEvent::EnterBegan { state: ST_SUB },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_SUB,
|
||||
@@ -143,6 +160,10 @@ async fn replay_full_lifecycle_matches_expected() {
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_SUB },
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST_ACTIVE),
|
||||
to: ST_SUB,
|
||||
},
|
||||
TraceEvent::TransitionComplete {
|
||||
from: Some(ST_IDLE),
|
||||
to: ST_ACTIVE,
|
||||
|
||||
Reference in new issue
Block a user