Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d7baf7236
|
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/
|
||||
Reference in new issue
Block a user