From 4d7baf72368242c14ac1b8d79c845b3f134bbbde Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 22 Apr 2026 16:54:36 -0600 Subject: [PATCH] fix!: up-transitions no longer exit and re-enter the target ancestor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .config/nextest.toml | 8 + .gitignore | 7 + CHANGELOG.md | 20 +++ Cargo.lock | 4 +- Cargo.toml | 2 +- docs/000. original-hsmc-spec.md | 73 +++++++-- hsmc-macros/src/codegen.rs | 64 ++++++-- hsmc/Cargo.toml | 2 +- hsmc/src/error.rs | 17 ++ hsmc/src/lib.rs | 282 ++++++++++++++++++++++++++++++++ hsmc/tests/behavior.rs | 143 +++++++++++++++- justfile | 184 +++++++++++++++++++++ 12 files changed, 774 insertions(+), 32 deletions(-) create mode 100644 .config/nextest.toml create mode 100644 justfile diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 0000000..79ef55b --- /dev/null +++ b/.config/nextest.toml @@ -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 diff --git a/.gitignore b/.gitignore index 857ec4d..04eb9d8 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 559ee3c..37ac65e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 6b72cf3..c247fb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 1e269ed..254bf99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 "] diff --git a/docs/000. original-hsmc-spec.md b/docs/000. original-hsmc-spec.md index 5035758..52f3fe6 100644 --- a/docs/000. original-hsmc-spec.md +++ b/docs/000. original-hsmc-spec.md @@ -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 { /// 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) -> 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 ``` diff --git a/hsmc-macros/src/codegen.rs b/hsmc-macros/src/codegen.rs index 53998b9..2cc4744 100644 --- a/hsmc-macros/src/codegen.rs +++ b/hsmc-macros/src/codegen.rs @@ -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, 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 { diff --git a/hsmc/Cargo.toml b/hsmc/Cargo.toml index 3864bf6..2e28b1d 100644 --- a/hsmc/Cargo.toml +++ b/hsmc/Cargo.toml @@ -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 } diff --git a/hsmc/src/error.rs b/hsmc/src/error.rs index a404c30..0f54bda 100644 --- a/hsmc/src/error.rs +++ b/hsmc/src/error.rs @@ -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: ::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" + ); + } +} diff --git a/hsmc/src/lib.rs b/hsmc/src/lib.rs index 09d86ee..134649d 100644 --- a/hsmc/src/lib.rs +++ b/hsmc/src/lib.rs @@ -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 = 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 = 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 = 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 = 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 = 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: ::push -> Ok(()) + #[test] + fn deque_queue_push_surfaces_overflow() { + let mut d: heapless::Deque = heapless::Deque::new(); + let q: &mut dyn QueuePush = &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) + ); + } +} diff --git a/hsmc/tests/behavior.rs b/hsmc/tests/behavior.rs index b6a7787..692c097 100644 --- a/hsmc/tests/behavior.rs +++ b/hsmc/tests/behavior.rs @@ -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"]); } } diff --git a/justfile b/justfile new file mode 100644 index 0000000..f421163 --- /dev/null +++ b/justfile @@ -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/