fix!: up-transitions no longer exit and re-enter the target ancestor

A transition whose target is already active (an ancestor of the
innermost active state) now unwinds only the subtree strictly below
the target. The target's entry/exit actions do not fire and its
default() does not re-descend — you cannot re-enter a state you never
left. The old behavior could loop infinitely when a child transitioned
to its own parent.

Self-transitions (target == innermost) and lateral transitions are
unchanged. See spec §2.6 and tests §T2.8 / T2.8b / T2.8c.

Also in this release:

* Full workspace justfile (check, test, coverage, mutants, pre-release)
* Unit tests covering all __private internals; `cargo mutants` now exits
  clean (38 caught, 178 unviable, 0 missed, 0 timeouts).
* .config/nextest.toml with slow-timeout/terminate-after so hung tests
  fail fast rather than wedging cargo-mutants runs.

BREAKING CHANGE: transition-to-ancestor semantics. Machines that relied
on the old re-entry behavior of up-transitions will observe different
exit/entry action sequences.
This commit is contained in:
swaits committed 2026-04-22 18:22:04 -06:00
1 parent bc7e17e58e
commit 4d7baf7236
12 files changed
+774 -32

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# Kill any single test that runs longer than 15s. The machine tests use
# `run()` loops that rely on the timer subsystem progressing — when a
# mutation breaks timer semantics a test can spin forever. Without a
# terminate-after budget the whole `cargo mutants` run is scored TIMEOUT
# instead of CAUGHT even when unit tests have already flagged the bug.
[profile.default.slow-timeout]
period = "5s"
terminate-after = 3
+7
View File
@@ -2,6 +2,13 @@
/target
**/target/
# Tooling output (coverage, mutation testing, profiling, mise cache)
/coverage/
/mutants.out/
/mutants.out.old/
mutation_history.json
*.profraw
# Local Claude Code config (per-host overrides)
.claude/settings.local.json
+20
View File
@@ -4,6 +4,26 @@ 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.2.0 — 2026-04-22
### Fixed — behavior-visible (breaking)
- **Up-transitions no longer exit and re-enter the target ancestor.**
A transition whose target is already active (i.e., the target is on
the path from root to the innermost active state, and the target is
not itself the innermost) now unwinds only the subtree strictly
below the target. The target's `exit:` / `entry:` actions do NOT
fire, and its `default(...)` does NOT re-descend. You cannot
re-enter a state you never left. The old semantics (exit target,
re-enter, re-descend to default) was incorrect and could produce
infinite re-entry when a child transitioned to its own parent.
See spec §2.6, §T2.8, §T2.8b, §T2.8c.
- Self-transitions (`target == innermost`) are unchanged: the target
is still exited and re-entered, timers still restart.
- Lateral transitions (target not on the active path) are unchanged.
- `current_state()` may now return a composite state when it became
innermost via an up-transition.
## 0.1.0 — 2026-04-22
Initial release.
Generated
+2 -2
View File
@@ -391,7 +391,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hsmc"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"crossterm",
"embassy-futures",
@@ -407,7 +407,7 @@ dependencies = [
[[package]]
name = "hsmc-macros"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"hsmc",
"proc-macro2",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["hsmc", "hsmc-macros"]
[workspace.package]
version = "0.1.0"
version = "0.2.0"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Stephen Waits <steve@waits.net>"]
+60 -13
View File
@@ -41,9 +41,9 @@ A state is a named node in the state hierarchy. States may be nested arbitrarily
### 2.3 State Configuration
At any point in time, the machine is in exactly one "state configuration," which is a path from the root state down through the hierarchy to a leaf (or to a state with no children). If state `A` contains state `B` which contains state `C`, and `C` is the active leaf, then the machine is simultaneously "in" states `A`, `B`, and `C`.
At any point in time, the machine is in exactly one "state configuration," which is a path from the root state down through the hierarchy to the **innermost active state**. The innermost active state may be a state with no declared children, or a composite state that has become the innermost active state via a transition to an already-active ancestor (§2.6) and therefore has no active child. If state `A` contains state `B` which contains state `C`, and `C` is the innermost active state, then the machine is simultaneously "in" states `A`, `B`, and `C`.
When entering a state that has children, the machine automatically descends into the `default` child, recursively, until it reaches a state with no children or no default.
When a state becomes newly active — that is, it was not in the previous configuration — and it has children, the machine automatically descends into the `default` child, recursively, until it reaches a state with no children or no default. A transition that targets a state which is already active (see §2.6) does NOT trigger default descent, because the target is not newly entered.
### 2.4 Events
@@ -60,12 +60,23 @@ A trigger is either:
A transition specifies: when trigger `T` occurs in state `S`, move to state `Target`.
Transitions are classified by the relationship between `Target` and the **innermost active state** `I` at the time the transition fires:
- **Up-transition**: `Target` is already active — that is, `Target` lies on the path from the root down to `I`, and `Target ≠ I`. You cannot enter a state you never left. An up-transition therefore only unwinds the subtree strictly below `Target`.
- **Self-transition**: `Target = I`. The innermost active state is exited and re-entered (timers restart; entry/exit actions fire).
- **Lateral transition**: `Target` is not on the active path. Standard LCA semantics apply.
The transition algorithm:
1. Find the least common ancestor (LCA) of the source state and the target state.
2. Exit states from the current active leaf up to (but not including) the LCA, calling exit actions in inner-to-outer order.
3. Enter states from the LCA down to the target state, calling entry actions in outer-to-inner order.
4. If the target state has children, descend into defaults recursively, calling entry actions along the way.
1. Classify the transition as up, self, or lateral (see above).
2. Compute the effective LCA:
- **Up**: effective LCA = `Target`.
- **Self**: effective LCA = parent of `Target` (so `Target` is exited and re-entered).
- **Lateral**: effective LCA = LCA(`I`, `Target`).
3. Exit states from `I` upward, calling exit actions inner-to-outer, stopping **before** the effective LCA. (The effective LCA is never exited.)
4. Entry behavior:
- **Up**: no entry actions fire. `Target` was already active and remains so; it is now the innermost active state, with no active child.
- **Self** and **Lateral**: enter states from the effective LCA down to `Target` (outer-to-inner), then if `Target` has children, descend into defaults recursively, calling entry actions along the way.
Transitions may target any state in the entire machine, not just siblings.
@@ -79,7 +90,7 @@ If both actions and a transition are registered on the same trigger in the same
### 2.8 Event Dispatch
When an event is received (or a timer fires), the machine searches for a matching transition or action starting from the current active leaf state and walking up toward the root. The first state that has a handler for the trigger processes it. If no state handles the trigger, the event is silently discarded.
When an event is received (or a timer fires), the machine searches for a matching transition or action starting from the innermost active state and walking up toward the root. The first state that has a handler for the trigger processes it. If no state handles the trigger, the event is silently discarded.
Timer triggers do NOT bubble. A timer belongs to the state that declared it and only fires in that state's scope. Event triggers DO bubble up the hierarchy.
@@ -91,13 +102,15 @@ Timer triggers do NOT bubble. A timer belongs to the state that declared it and
### 2.10 Default State
`default(ChildState)` specifies the initial child state. It is required in any state that has children. It is required at the root level. The referenced state must be a direct child of the containing state. This is enforced at compile time.
`default(ChildState)` specifies the child state to descend into when the containing state is newly entered (either at initial machine startup, or via a lateral/self-transition that enters it fresh). It is required in any state that has children. It is required at the root level. The referenced state must be a direct child of the containing state. This is enforced at compile time.
`default` does NOT fire when a state becomes the innermost active state via an up-transition (§2.6): the parent was already active, so there is no "new entry" to descend from.
### 2.11 Terminate
`terminate(EventVariant)` may only appear at the root level of the machine. It specifies that when the given event is received, the machine shuts down. Shutdown means:
1. Exit the current active leaf state, calling its exit actions.
1. Exit the innermost active state, calling its exit actions.
2. Exit each ancestor state up to and including the root state, calling exit actions in inner-to-outer order.
3. The machine is now terminated. `run()` returns. `is_terminated()` returns true.
@@ -246,7 +259,9 @@ impl<Name> {
/// Returns true if the machine has terminated.
fn is_terminated(&self) -> bool;
/// Returns the current active state configuration (leaf state).
/// Returns the innermost active state (see §2.3). This is typically a
/// leaf, but may be a composite state that became innermost via an
/// up-transition (§2.6).
fn current_state(&self) -> <Name>State;
/// Consumes the machine, returns the user's context.
@@ -633,15 +648,47 @@ Then: Exit order: [B_exit, A_exit]
current_state() == C
```
#### T2.8: Transition to an ancestor state
#### T2.8: Up-transition to an ancestor state
A transition whose target is already active (an ancestor of the innermost active state) only unwinds the subtree strictly below the target. The target itself is not exited and not re-entered. Its `default` is not followed — it was already active, so there is no "new entry."
```
Given: Machine with:
state A { default(B); state B { transition(Up) => A } }
Machine is in B.
When: Event Up is delivered
Then: Exit order: [B_exit, A_exit]
Entry order: [A_entry, B_entry] (re-enters A, descends to default B)
Then: Exit order: [B_exit]
Entry order: []
current_state() == A
```
#### T2.8b: Up-transition across multiple levels
```
Given: Machine with:
state A { default(B); state B { default(C); state C { transition(Up) => A } } }
Machine is in C.
When: Event Up is delivered
Then: Exit order: [C_exit, B_exit]
Entry order: []
current_state() == A
```
#### T2.8c: Handler on ancestor, target is a mid-ancestor
When the handler bubbles up to an ancestor, classification is based on the innermost active state at the time the transition fires — not on where the handler was found. If the target is on the active path and not the innermost, it is still an up-transition.
```
Given: Machine with:
state A {
default(B);
transition(Up) => B; // handler on A, target B
state B { default(C); state C {} }
}
Machine is in C.
When: Event Up is delivered
Then: Exit order: [C_exit]
Entry order: []
current_state() == B
```
+54 -10
View File
@@ -1819,13 +1819,45 @@ pub fn generate(ir: &Ir) -> TokenStream {
__dispatch(&mut actx, aid, event).await;
}
// §2.6: classify transitions by the relationship between the
// innermost active state `I` and the target `T`:
// - Up-transition: T is already active (T is a strict ancestor
// of I). Unwind the subtree strictly below T; do NOT exit or
// re-enter T; do NOT descend into defaults. You cannot enter
// a state you never left.
// - Self-transition (T == I): external semantics — exit T and
// re-enter it, then descend into defaults if T has children.
// - Lateral: standard LCA semantics.
fn is_up_transition(current: Option<u16>, target: u16) -> bool {
let Some(mut node) = current else { return false; };
if node == target { return false; }
while let Some(p) = __PARENT[node as usize] {
if p == target { return true; }
node = p;
}
false
}
#[cfg(not(any(feature = "tokio", feature = "embassy")))]
fn transition(&mut self, source_matched_state: u16, target: u16) {
let mut lca = Self::lca(source_matched_state, target);
if let Some(l) = lca {
if l == target {
lca = __PARENT[l as usize];
fn transition(&mut self, _source_matched_state: u16, target: u16) {
if Self::is_up_transition(self.current, target) {
if let Some(mut cur) = self.current {
while cur != target {
self.exit_state(cur);
match __PARENT[cur as usize] {
Some(p) => cur = p,
None => break,
}
}
}
self.current = Some(target);
return;
}
let mut lca = Self::lca(self.current.unwrap_or(target), target);
if self.current == Some(target) {
// Self-transition: bump LCA so target is exited and re-entered.
lca = __PARENT[target as usize];
}
if let Some(mut cur) = self.current {
loop {
@@ -1853,12 +1885,24 @@ pub fn generate(ir: &Ir) -> TokenStream {
}
#[cfg(any(feature = "tokio", feature = "embassy"))]
async fn transition(&mut self, source_matched_state: u16, target: u16) {
let mut lca = Self::lca(source_matched_state, target);
if let Some(l) = lca {
if l == target {
lca = __PARENT[l as usize];
async fn transition(&mut self, _source_matched_state: u16, target: u16) {
if Self::is_up_transition(self.current, target) {
if let Some(mut cur) = self.current {
while cur != target {
self.exit_state(cur).await;
match __PARENT[cur as usize] {
Some(p) => cur = p,
None => break,
}
}
}
self.current = Some(target);
return;
}
let mut lca = Self::lca(self.current.unwrap_or(target), target);
if self.current == Some(target) {
lca = __PARENT[target as usize];
}
if let Some(mut cur) = self.current {
loop {
+1 -1
View File
@@ -19,7 +19,7 @@ tokio = ["dep:tokio"]
embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"]
[dependencies]
hsmc-macros = { path = "../hsmc-macros", version = "0.1.0" }
hsmc-macros = { path = "../hsmc-macros", version = "0.2.0" }
heapless = "0.8"
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
embassy-sync = { version = "0.8", optional = true }
+17
View File
@@ -18,3 +18,20 @@ impl core::fmt::Display for HsmcError {
#[cfg(feature = "tokio")]
impl std::error::Error for HsmcError {}
#[cfg(test)]
mod tests {
use super::*;
extern crate alloc;
use alloc::format;
// Kills: <impl Display for HsmcError>::fmt -> Ok(Default::default())
#[test]
fn display_messages_are_specific() {
assert_eq!(format!("{}", HsmcError::QueueFull), "event queue is full");
assert_eq!(
format!("{}", HsmcError::AlreadyTerminated),
"machine has already terminated"
);
}
}
+282
View File
@@ -271,3 +271,285 @@ pub mod __private {
}
}
}
#[cfg(test)]
mod private_internals {
//! Direct unit tests on `__private` internals. The behavior suite covers
//! these through generated code, but mutation testing needs tight, fast
//! tests that pin the exact semantics each helper is supposed to uphold.
use super::__private::*;
use crate::HsmcError;
use core::time::Duration;
// ───── EventQueue ─────
// Kills: push -> Ok(()) (must actually enqueue, not swallow)
#[test]
fn event_queue_push_and_pop() {
let mut q: EventQueue<u32, 2> = EventQueue::new();
assert!(q.push(10).is_ok());
assert!(q.push(20).is_ok());
assert_eq!(q.pop(), Some(10));
assert_eq!(q.pop(), Some(20));
assert_eq!(q.pop(), None);
}
// Kills: push -> Ok(()) for the overflow branch (QueueFull must be surfaced)
#[test]
fn event_queue_push_overflow_returns_queue_full() {
let mut q: EventQueue<u32, 2> = EventQueue::new();
assert!(q.push(1).is_ok());
assert!(q.push(2).is_ok());
assert_eq!(q.push(3), Err(HsmcError::QueueFull));
}
// Kills: pop -> None (must drain in FIFO order)
#[test]
fn event_queue_pop_is_fifo() {
let mut q: EventQueue<u32, 4> = EventQueue::new();
q.push(1).unwrap();
q.push(2).unwrap();
q.push(3).unwrap();
assert_eq!(q.pop(), Some(1));
assert_eq!(q.pop(), Some(2));
assert_eq!(q.pop(), Some(3));
}
// Kills: is_empty -> true | is_empty -> false
#[test]
fn event_queue_is_empty_tracks_contents() {
let mut q: EventQueue<u32, 2> = EventQueue::new();
assert!(q.is_empty()); // kills is_empty -> false
q.push(1).unwrap();
assert!(!q.is_empty()); // kills is_empty -> true
q.pop();
assert!(q.is_empty());
}
// Kills: clear with ()
#[test]
fn event_queue_clear_drains() {
let mut q: EventQueue<u32, 4> = EventQueue::new();
q.push(1).unwrap();
q.push(2).unwrap();
q.push(3).unwrap();
q.clear();
assert!(q.is_empty());
assert_eq!(q.pop(), None);
}
// ───── QueuePush for heapless::Deque ─────
// Kills: <impl QueuePush for heapless::Deque>::push -> Ok(())
#[test]
fn deque_queue_push_surfaces_overflow() {
let mut d: heapless::Deque<u32, 2> = heapless::Deque::new();
let q: &mut dyn QueuePush<u32> = &mut d;
assert!(q.push(1).is_ok());
assert!(q.push(2).is_ok());
assert_eq!(q.push(3), Err(HsmcError::QueueFull));
}
// ───── TimerTable::start ─────
// Kills L173 && -> ||, L173 first == -> !=, L173 second == -> !=
//
// Three timers chosen so each mutation lands on a different outcome:
// (1,10,100) (1,20,200) (2,10,300); then re-start (1,10,500) to reset.
// Normal: three distinct entries; (1,10) shows 500ns.
// && -> ||: re-start (1,10,500) matches (1,20) on state-1, rewrites it.
// state == -> !=: the second call (1,20) overwrites the first (1,10).
// trigger == -> !=: the re-start (1,10,500) doesn't find (1,10); pushes a 4th.
#[test]
fn timer_start_matches_state_and_trigger_exactly() {
let mut t: TimerTable<8> = TimerTable::new();
t.start(1, 10, Duration::from_nanos(100));
t.start(1, 20, Duration::from_nanos(200));
t.start(2, 10, Duration::from_nanos(300));
assert_eq!(t.entries.len(), 3);
t.start(1, 10, Duration::from_nanos(500));
assert_eq!(t.entries.len(), 3); // reset, not appended
let find = |s, trg| {
t.entries
.iter()
.find(|e| e.state == s && e.trigger == trg)
.map(|e| e.remaining_ns)
};
assert_eq!(find(1, 10), Some(500));
assert_eq!(find(1, 20), Some(200));
assert_eq!(find(2, 10), Some(300));
}
// Kills: start with () (must actually push an entry)
#[test]
fn timer_start_pushes_new_entry() {
let mut t: TimerTable<4> = TimerTable::new();
t.start(7, 42, Duration::from_nanos(999));
assert_eq!(t.entries.len(), 1);
assert_eq!(t.entries[0].state, 7);
assert_eq!(t.entries[0].trigger, 42);
assert_eq!(t.entries[0].remaining_ns, 999);
}
// ───── TimerTable::cancel_state ─────
// Kills: cancel_state with (), != -> ==
//
// Start three timers, cancel state=1; expect only state=2 survives.
// cancel_state with (): all three survive.
// != -> ==: retains only state=1 entries, drops state=2. Inverted.
#[test]
fn timer_cancel_state_removes_matching_only() {
let mut t: TimerTable<8> = TimerTable::new();
t.start(1, 10, Duration::from_nanos(100));
t.start(1, 20, Duration::from_nanos(200));
t.start(2, 10, Duration::from_nanos(300));
t.cancel_state(1);
assert_eq!(t.entries.len(), 1);
assert_eq!(t.entries[0].state, 2);
assert_eq!(t.entries[0].trigger, 10);
}
// ───── TimerTable::cancel_one ─────
// Kills: cancel_one with (), delete !, && -> ||, both == -> !=
//
// Start (1,10), (1,20), (2,10). Cancel (1,10). Expect {(1,20),(2,10)}.
// body (): nothing removed.
// delete !: keeps only (1,10).
// && -> ||: retains `!(state==s || trig==t)`, drops everything.
// state == -> !=: (1,10) survives, (1,20) dropped.
// trigger == -> !=: (1,10) survives, (1,20) dropped.
#[test]
fn timer_cancel_one_removes_exact_pair() {
let mut t: TimerTable<8> = TimerTable::new();
t.start(1, 10, Duration::from_nanos(100));
t.start(1, 20, Duration::from_nanos(200));
t.start(2, 10, Duration::from_nanos(300));
t.cancel_one(1, 10);
let has = |s, trg| t.entries.iter().any(|e| e.state == s && e.trigger == trg);
assert_eq!(t.entries.len(), 2);
assert!(!has(1, 10), "(1,10) must be cancelled");
assert!(has(1, 20), "(1,20) must remain");
assert!(has(2, 10), "(2,10) must remain");
}
// ───── TimerTable::decrement ─────
// Kills: decrement with ()
#[test]
fn timer_decrement_reduces_remaining() {
let mut t: TimerTable<4> = TimerTable::new();
t.start(1, 1, Duration::from_nanos(500));
t.decrement(Duration::from_nanos(200));
assert_eq!(t.entries[0].remaining_ns, 300);
}
// ───── TimerTable::pop_expired ─────
// Kills body mutations: None, Some((0,0)/(0,1)/(1,0)/(1,1))
// Uses state/trigger distinct from 0/1 so no fixed return matches.
// Also kills the "entry is removed" invariant.
#[test]
fn timer_pop_expired_returns_exact_pair_and_removes() {
let mut t: TimerTable<4> = TimerTable::new();
t.start(5, 7, Duration::from_nanos(0));
let depth = [0u8, 0, 0, 0, 0, 1, 0, 1];
assert_eq!(t.pop_expired(&depth), Some((5, 7)));
assert_eq!(t.entries.len(), 0);
}
// Kills L202: `remaining_ns == 0` -> `!= 0`
// A still-running timer must not be returned.
#[test]
fn timer_pop_expired_ignores_nonzero_remaining() {
let mut t: TimerTable<4> = TimerTable::new();
t.start(5, 7, Duration::from_nanos(100));
let depth = [0u8, 0, 0, 0, 0, 1, 0, 1];
assert_eq!(t.pop_expired(&depth), None);
assert_eq!(t.entries.len(), 1); // not removed
}
// Kills L208: depth `>` -> `==` or `<`
// Two expired entries at different depths; deepest must win.
#[test]
fn timer_pop_expired_picks_deepest() {
let mut t: TimerTable<4> = TimerTable::new();
t.start(1, 100, Duration::from_nanos(0)); // shallow
t.start(2, 200, Duration::from_nanos(0)); // deeper
let depth = [0u8, 1, 3, 0];
assert_eq!(t.pop_expired(&depth), Some((2, 200)));
}
// Kills L208: `>` -> `>=`
// Two expired entries at *equal* depth; first-declared must win.
// With `>=`, the later entry replaces the earlier on tie.
#[test]
fn timer_pop_expired_breaks_ties_by_declaration_order() {
let mut t: TimerTable<4> = TimerTable::new();
t.start(1, 100, Duration::from_nanos(0));
t.start(2, 200, Duration::from_nanos(0));
let depth = [0u8, 2, 2, 0]; // both at depth 2
assert_eq!(t.pop_expired(&depth), Some((1, 100)));
}
// ───── TimerTable::min_remaining ─────
// Kills L223 body None + body Some(default)
// Kills L224 / -> * and / -> %
// Kills L225 % -> + and % -> /
//
// 1_500_000_000 ns = 1.5s. Every mutation yields a clearly wrong duration:
// None: returns None, not Some(1.5s).
// Some(default): returns Duration::ZERO.
// / -> *: secs = ns * 1e9 overflows (or caught by `as u64`); not 1.
// / -> %: secs = ns % 1e9 = 5e8; not 1.
// % -> +: rem = ns + 1e9 overflows u32; not 5e8.
// % -> /: rem = ns / 1e9 = 1; not 5e8.
#[test]
fn timer_min_remaining_exact_duration() {
let mut t: TimerTable<4> = TimerTable::new();
t.start(1, 1, Duration::from_nanos(1_500_000_000));
assert_eq!(t.min_remaining(), Some(Duration::new(1, 500_000_000)));
}
// Kills: min_remaining -> None (always). Explicit contrast to the empty case.
#[test]
fn timer_min_remaining_none_when_empty() {
let t: TimerTable<4> = TimerTable::new();
assert_eq!(t.min_remaining(), None);
}
// Picks the smallest across multiple entries.
#[test]
fn timer_min_remaining_picks_smallest() {
let mut t: TimerTable<4> = TimerTable::new();
t.start(1, 1, Duration::from_nanos(9_999));
t.start(2, 2, Duration::from_nanos(100));
t.start(3, 3, Duration::from_nanos(5_000));
assert_eq!(t.min_remaining(), Some(Duration::from_nanos(100)));
}
// ───── duration_from_secs_f64 ─────
// Kills body -> Default, * -> +, * -> /
//
// 1.5s -> 1_500_000_000ns exactly.
// body -> Default: returns ZERO.
// * -> +: secs + 1e9 = 1_000_000_001.5 -> 1_000_000_001 ns.
// * -> /: secs / 1e9 = 1.5e-9 -> 0 ns.
#[test]
fn duration_from_secs_f64_multiplies_correctly() {
assert_eq!(
duration_from_secs_f64(1.5),
Duration::from_nanos(1_500_000_000)
);
assert_eq!(
duration_from_secs_f64(0.25),
Duration::from_nanos(250_000_000)
);
}
}
+138 -5
View File
@@ -717,17 +717,150 @@ mod t2_8 {
#[test]
fn t2_8_transition_to_ancestor() {
// §2.6: up-transition. Target A is already active; we unwind only
// the subtree strictly below A. A itself is not exited or
// re-entered, and its default(B) does not re-fire.
let mut m = T28::new(TestCtx::default());
m.step(Duration::ZERO);
assert_eq!(m.context().logs(), ["a_entry", "b_entry"]);
m.context_mut().log.clear();
m.send(Ev::Up).unwrap();
m.step(Duration::ZERO);
assert_eq!(m.current_state(), T28State::B);
assert_eq!(
m.context().logs(),
["b_exit", "a_exit", "a_entry", "b_entry"]
);
assert_eq!(m.current_state(), T28State::A);
assert_eq!(m.context().logs(), ["b_exit"]);
}
}
// ---- T2.8b: up-transition across multiple levels ----
mod t2_8b {
use super::*;
#[derive(Debug, Clone)]
pub enum Ev {
Up,
}
statechart! {
T28b {
context: TestCtx;
events: Ev;
default(A);
state A {
default(B);
entry: a_entry;
exit: a_exit;
state B {
default(C);
entry: b_entry;
exit: b_exit;
state C {
entry: c_entry;
exit: c_exit;
on(Up) => A;
}
}
}
}
}
impl T28bActions for T28bActionContext<'_> {
fn a_entry(&mut self) {
self.log.push("a_entry".into());
}
fn a_exit(&mut self) {
self.log.push("a_exit".into());
}
fn b_entry(&mut self) {
self.log.push("b_entry".into());
}
fn b_exit(&mut self) {
self.log.push("b_exit".into());
}
fn c_entry(&mut self) {
self.log.push("c_entry".into());
}
fn c_exit(&mut self) {
self.log.push("c_exit".into());
}
}
#[test]
fn t2_8b_up_transition_across_levels() {
let mut m = T28b::new(TestCtx::default());
m.step(Duration::ZERO);
assert_eq!(m.context().logs(), ["a_entry", "b_entry", "c_entry"]);
m.context_mut().log.clear();
m.send(Ev::Up).unwrap();
m.step(Duration::ZERO);
assert_eq!(m.current_state(), T28bState::A);
assert_eq!(m.context().logs(), ["c_exit", "b_exit"]);
}
}
// ---- T2.8c: handler on ancestor, target is a mid-ancestor ----
mod t2_8c {
use super::*;
#[derive(Debug, Clone)]
pub enum Ev {
Up,
}
statechart! {
T28c {
context: TestCtx;
events: Ev;
default(A);
state A {
default(B);
entry: a_entry;
exit: a_exit;
on(Up) => B; // handler on A, target B
state B {
default(C);
entry: b_entry;
exit: b_exit;
state C {
entry: c_entry;
exit: c_exit;
}
}
}
}
}
impl T28cActions for T28cActionContext<'_> {
fn a_entry(&mut self) {
self.log.push("a_entry".into());
}
fn a_exit(&mut self) {
self.log.push("a_exit".into());
}
fn b_entry(&mut self) {
self.log.push("b_entry".into());
}
fn b_exit(&mut self) {
self.log.push("b_exit".into());
}
fn c_entry(&mut self) {
self.log.push("c_entry".into());
}
fn c_exit(&mut self) {
self.log.push("c_exit".into());
}
}
#[test]
fn t2_8c_up_transition_classified_by_innermost_not_handler() {
// Handler bubbles from C to A. Target B is on the active path and
// is not the innermost, so this is an up-transition — even though
// the handler was found on A (not on C or B).
let mut m = T28c::new(TestCtx::default());
m.step(Duration::ZERO);
assert_eq!(m.context().logs(), ["a_entry", "b_entry", "c_entry"]);
m.context_mut().log.clear();
m.send(Ev::Up).unwrap();
m.step(Duration::ZERO);
assert_eq!(m.current_state(), T28cState::B);
assert_eq!(m.context().logs(), ["c_exit"]);
}
}
+184
View File
@@ -0,0 +1,184 @@
# justfile for the hsmc workspace (hsmc + hsmc-macros)
# High-level development and CI workflows
# =============================================================================
# Configuration
# =============================================================================
# Treat all warnings as errors
rustflags := "-D warnings"
# Code coverage target percentage (fail if below this threshold)
coverage_target := "80"
# Timeout for coverage analysis in seconds
coverage_timeout := "600"
# =============================================================================
# Default recipe - show available recipes
default:
@just --list
# =============================================================================
# Primary Development Workflows
# =============================================================================
# Quick development loop: format + lint + fast tests (default features only)
dev:
#!/usr/bin/env bash
set -e
echo "🔄 Running development checks..."
cargo fmt
RUSTFLAGS="{{rustflags}}" cargo clippy --workspace --all-targets &
RUSTFLAGS="{{rustflags}}" cargo nextest run --workspace &
wait
echo "✅ Development checks passed"
# Pre-commit checks across all feature combinations (tokio/embassy are mutually exclusive)
check:
@echo "Running format check..."
cargo fmt --all -- --check
@echo ""
@echo "Running clippy on all feature combinations..."
RUSTFLAGS="{{rustflags}}" cargo clippy --workspace --all-targets --no-default-features
RUSTFLAGS="{{rustflags}}" cargo clippy --workspace --all-targets --features tokio
RUSTFLAGS="{{rustflags}}" cargo clippy --workspace --all-targets --features embassy
@echo ""
@echo "Verifying compilation on all feature combinations..."
RUSTFLAGS="{{rustflags}}" cargo test --workspace --no-default-features --no-run
RUSTFLAGS="{{rustflags}}" cargo test --workspace --features tokio --no-run
RUSTFLAGS="{{rustflags}}" cargo test --workspace --features embassy --no-run
@echo ""
@echo "✅ All checks passed"
# Run all tests with comprehensive feature coverage
test:
@echo "Running tests with all feature combinations..."
RUSTFLAGS="{{rustflags}}" cargo nextest run --workspace --no-default-features
RUSTFLAGS="{{rustflags}}" cargo nextest run --workspace --features tokio
RUSTFLAGS="{{rustflags}}" cargo nextest run --workspace --features embassy
@echo ""
@echo "Running doctests..."
cargo test --doc --workspace
@echo "✅ All feature combinations tested"
# Before pushing: check + test + coverage
pre-push: check test coverage
@echo ""
@echo "✅ Pre-push checks passed - safe to push!"
# Comprehensive pre-release validation
pre-release: check test coverage audit mutants examples
#!/usr/bin/env bash
set -e
# Verify documentation version matches crate version
echo ""
echo "📋 Verifying documentation version..."
VERSION=$(grep '^version = ' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
MAJOR_MINOR=$(echo $VERSION | cut -d. -f1-2)
# README uses the braced form: `hsmc = { version = "X.Y", ... }`
if ! grep -Eq "hsmc = \{[^}]*version = \"$MAJOR_MINOR(\.[0-9]+)?\"" README.md; then
echo "❌ ERROR: README.md should reference version $MAJOR_MINOR (crate is $VERSION)"
echo " Update the install snippet in README.md"
exit 1
fi
echo "✅ Documentation version correct: $MAJOR_MINOR"
# Workspace publish dry-run: hsmc-macros must publish before hsmc
echo ""
echo "📦 Verifying packages..."
cargo publish --dry-run -p hsmc-macros
cargo publish --dry-run -p hsmc
cargo package --list -p hsmc-macros > /tmp/hsmc-macros-files.txt
cargo package --list -p hsmc > /tmp/hsmc-files.txt
echo "✅ Packages verified"
echo ""
echo "✅ All pre-release checks passed!"
echo " - Code formatted and linted"
echo " - All tests passing on {default, tokio, embassy}"
echo " - Code coverage ≥ {{coverage_target}}%"
echo " - No security vulnerabilities"
echo " - Mutation testing complete"
echo " - All examples run / build successfully"
echo " - Documentation version verified"
echo " - Packages ready for publication"
echo ""
echo "📦 Ready to publish! Run (in order):"
echo " cargo publish -p hsmc-macros"
echo " cargo publish -p hsmc"
# =============================================================================
# Quality & Security
# =============================================================================
# Run code coverage analysis (tokio feature covers the widest surface area)
coverage:
@echo "Running code coverage analysis (target: {{coverage_target}}%)..."
cargo llvm-cov nextest --workspace --features tokio --html --output-dir coverage --fail-under-lines {{coverage_target}}
@echo "Coverage report: coverage/html/index.html"
# Mutation testing with cargo-mutants
mutants:
@echo "Running mutation testing..."
@echo "⚠️ This will use significant CPU and memory resources!"
cargo mutants \
--no-shuffle \
--test-tool nextest \
--jobs 3 \
--features tokio \
-vV
# Security audit + dependency checks
audit:
@echo "🔒 Running security checks..."
cargo audit
@echo "✅ Security checks passed"
# Run Miri for UB detection (default features only; tokio/embassy rely on runtime primitives Miri does not support)
miri:
@echo "Running Miri tests for UB detection (no-default-features)..."
cargo +nightly miri test --workspace --no-default-features
# =============================================================================
# Documentation & Utilities
# =============================================================================
# Build documentation and open in browser
doc:
cargo doc --no-deps --workspace --features tokio --open
# Format code
fmt:
cargo fmt --all
# Show project information
info:
@echo "hsmc workspace"
@echo " hsmc - Hierarchical state machines (statecharts) with a declarative proc macro"
@echo " hsmc-macros - Procedural macro implementation for the hsmc crate"
@echo ""
@echo "Rust: $(rustc --version)"
@echo "Cargo: $(cargo --version)"
@echo ""
@echo "Dependencies:"
@cargo tree --depth 1 -p hsmc
# Run all examples (tokio examples execute; embassy_full is a lib crate, build-only)
examples:
@echo "Running tokio examples..."
cargo run --example microwave --features tokio
cargo run --example during_radio --features tokio
@echo ""
@echo "Building embassy_full example (lib crate)..."
cargo build --example embassy_full --features embassy
@echo "✅ All examples completed successfully"
# Clean build artifacts
clean:
cargo clean
rm -rf mutants.out/ mutants.out.old/ mutation_history.json
rm -rf coverage/