Files
hsmc/hsmc/tests/det_self_composite.rs
T
swaits 4ea7463089 feat(observe)!: unify journal and trace into single observation pipeline
The codegen now emits one `__chart_observe!` invocation per observable
atom. The macro fans out at compile time to whichever sinks are enabled:
the in-memory journal (`feature = "journal"`), `defmt` (`trace-defmt`),
`log` (`trace-log`), and/or `tracing` (`trace-tracing`). Trace and
journal share the same vocabulary by construction — they cannot
diverge. With no sink feature on, every site expands to `()` (zero
overhead). Multiple trace backends now compose; previously they were
mutually exclusive.

Trace lines use a logfmt-style format with a `[statechart:Name]` prefix
and `key=value` pairs, machine-parseable and greppable. Twenty verbs
cover every observable atom — entries/exits with begin/end pairs,
actions with kind, durings, transitions with the reason that triggered
them, event lifecycle (received/delivered/dropped/queued/failed),
timers, and termination.

BREAKING CHANGES (chart syntax unchanged; observability surface only):

- `TraceEvent::TransitionFired` now carries `reason: TransitionReason`
  showing whether the transition was driven by an event, a timer, or
  internal logic.
- New `TraceEvent` variants for phase pairing: `EnterBegan`, `ExitBegan`,
  `TransitionComplete`, `EventReceived`.
- Trace prefix changed from `[Name]` to `[statechart:Name]`.
- The chart-level `trace;` keyword is parsed for backwards compatibility
  but no longer gates emission.
- `__chart_trace!` / `__chart_journal!` macros removed (doc-hidden).

Verification: 159 integration tests pass, 0 missed mutants, miri 68
tests clean, Creusot 14/14 VCs discharged. Zero overhead confirmed via
`cargo expand` — every observation site expands to `{}` with no sinks
enabled.
2026-04-28 15:24:14 -06:00

182 lines
5.6 KiB
Rust

#![allow(dead_code)]
//! Deterministic-flow tests for SELF-TRANSITION on a composite (re-enters with default-descend).
//!
//! Spec section "Transitioning to yourself":
//! If the target happens to be a composite (it has children, but no child is
//! active right now because you got there via an up-transition), the same
//! default-child rule still applies on re-entry: exit the composite,
//! re-enter it, then descend through its default chain to a leaf.
//!
//! To get a composite as the innermost active state, this chart uses:
//! 1. UpToParent (D → Parent) → Parent is now innermost composite.
//! 2. SelfMid (Parent → Parent) → exit Parent, re-enter Parent, default-descend.
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
UpToParent, // D → Parent (up-transition)
SelfParent, // Parent → Parent (self-transition on composite)
Halt,
}
statechart! {
Sc {
context: Ctx;
events: Ev;
default(Parent);
terminate(Halt);
state Parent {
entry: p_in;
exit: p_out;
default(Child);
on(SelfParent) => Parent;
state Child {
entry: c_in;
exit: c_out;
default(Leaf);
state Leaf {
entry: l_in;
exit: l_out;
on(UpToParent) => Parent;
}
}
}
}
}
impl ScActions for ScActionContext<'_> {
async fn p_in(&mut self) {}
async fn p_out(&mut self) {}
async fn c_in(&mut self) {}
async fn c_out(&mut self) {}
async fn l_in(&mut self) {}
async fn l_out(&mut self) {}
}
const SR: u16 = 0;
const SP: u16 = 1;
const SC: u16 = 2;
const SL: u16 = 3;
#[tokio::test(flavor = "current_thread")]
async fn det_self_composite_default_descends_after_re_entry() {
// Path: initial → Leaf. UpToParent → Parent (up). SelfParent → exit Parent,
// re-enter Parent, default-descend (Parent → Child → Leaf).
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Sc::new(Ctx);
let _ = m.dispatch(Ev::UpToParent).await;
let _ = m.dispatch(Ev::SelfParent).await;
let j = m.take_journal();
// After SelfParent, expect: exit Parent → re-enter Parent → enter Child → enter Leaf.
let self_idx = j
.iter()
.rposition(|e| {
matches!(
e,
TraceEvent::TransitionFired {
from: Some(SP),
to: SP,
..
}
)
})
.unwrap();
let after = &j[self_idx..];
let exits: Vec<u16> = after
.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect();
let entered: Vec<u16> = after
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(exits, vec![SP], "self-transition exits target only");
assert_eq!(
entered,
vec![SP, SC, SL],
"re-enter target then default-descend"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_self_composite_action_kinds_in_order() {
// After the SelfParent transition: Exit p_out, Entry p_in, Entry c_in, Entry l_in.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Sc::new(Ctx);
let _ = m.dispatch(Ev::UpToParent).await;
let _ = m.dispatch(Ev::SelfParent).await;
let j = m.take_journal();
let self_idx = j
.iter()
.rposition(|e| {
matches!(
e,
TraceEvent::TransitionFired {
from: Some(SP),
to: SP,
..
}
)
})
.unwrap();
let kinds: Vec<ActionKind> = j[self_idx..]
.iter()
.filter_map(|e| match e {
TraceEvent::ActionInvoked { kind, .. } => Some(*kind),
_ => None,
})
.collect();
assert_eq!(
kinds,
vec![
ActionKind::Exit,
ActionKind::Entry,
ActionKind::Entry,
ActionKind::Entry
],
"exit Parent, then enter Parent, Child, Leaf"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_self_composite_byte_deterministic() {
let run = || async {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Sc::new(Ctx);
let _ = m.dispatch(Ev::UpToParent).await;
let _ = m.dispatch(Ev::SelfParent).await;
let _ = m.dispatch(Ev::Halt).await;
m.take_journal()
})
.await
};
let first = run().await;
for i in 1..10 {
assert_eq!(first, run().await, "diverged on run {i}");
}
}