feat(verify): discharge Creusot VCs end-to-end
Make `just verify` actually invoke Why3 + alt-ergo + z3 against every
Pearlite VC, and fix the mutant + miri pipelines along the way.
Verification crate
- Drop the const-generic CAP types for EventQueue / TimerTable.
Mirror with std::vec::Vec, whose creusot-std specs carry the
proof burden. Capacity behavior is exercised by the runtime's
own unit tests + correspondence tests.
- View impls are #[logic(open)] over a pub field so contracts on
push/pop/etc. can see the link to the inner Vec's @ view.
- 14/14 VCs discharge: EventQueue {new, push, pop, is_empty, len,
clear, Default}; TimerTable {new, len, is_empty, Default};
Entry derives.
- `cargo creusot prove --no-cache` always runs the SMT solvers;
`time` shows ~7s CPU on ~1.2s wall-clock = real parallel work.
Tooling
- verification/mise.toml pins nightly-2025-11-13 + rust-src,
rustc-dev, llvm-tools-preview, rustfmt; declares opam, python,
uv, and the project-local .venv. Inherits root mise.toml's just
+ cargo-* tools.
- just verify clones creusot v0.9.0 to ~/.local/share/hsmc-verify/
and runs the canonical ./INSTALL — that's the only way the
bundled prelude-generator + creusot-rustc actually build.
- just verify --no-cache forces SMT re-runs every time.
- Drop bespoke pin/install of cargo-creusot from mise.toml; the
cargo backend goes through cargo-binstall which can't pin a
multi-bin git workspace at a tag.
Mutants — was missing every JournalSink mutant
- Recipe runs with --features tokio,journal so journal_* and det_*
tests (gated on both) actually execute.
- Add clear_journal_empties_the_journal test in journal_smoke.rs
to kill the JournalSink::clear with-() mutant.
- Remove dead JournalSink::len + is_empty (called nowhere; only
existed as mutation-bait).
Result: 0 missed, 42 caught, 211 unviable (proc-macro emitted
code that mutants can't standalone-compile).
Miri — was hitting the trybuild glob isolation issue
- --exclude hsmc-macros (trybuild ui test isn't UB-relevant)
- --exclude hsmc-verification (different pinned toolchain)
- 66 tests pass under Miri, 0 UB.
Gitignore
- /verif/, /why3find.json, /.why3find/, **/.venv/ — all
regenerated by `just verify`. Repo stays clean of per-machine
prover timings + venv artifacts.
Misc
- Fix root-state targetability: `on(Trig) => <ChartName>;` resolves
to root id 0 in both ir.rs::resolve_transitions and
validate.rs's separate name_map.
- `set shell` and `set script-interpreter` route every justfile
recipe through `mise exec`, so all subshells see the right
Rust + opam + cargo-* tools regardless of how `just` was
invoked. No shebangs anywhere.
This commit is contained in:
33 files changed
+2943
-2148
No files matched your search
@@ -13,6 +13,14 @@ mutation_history.json
|
||||
# Why3 to read. Regenerated on every `just verify` run.
|
||||
/verif/
|
||||
|
||||
# Creusot/why3find config — `cargo creusot init` regenerates with
|
||||
# defaults appropriate to the local prover install. The `profile`
|
||||
# section caches per-machine prover timings that aren't portable.
|
||||
# `just verify` regenerates if missing.
|
||||
/why3find.json
|
||||
# why3find's per-run cache directory.
|
||||
/.why3find/
|
||||
|
||||
# Project-local Python venv created by mise's `_.python.venv` config.
|
||||
**/.venv/
|
||||
|
||||
|
||||
Generated
+4
-2
@@ -70,7 +70,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "creusot-std"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/creusot-rs/creusot?tag=v0.9.0#fd089be0e96442f5e71605fbb129e6d6f7b264f3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b6989d2fc081ac6e3f08fe18e4461b29b3e72fbcba78058131892d000f06bab"
|
||||
dependencies = [
|
||||
"creusot-std-proc",
|
||||
"num-rational",
|
||||
@@ -79,7 +80,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "creusot-std-proc"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/creusot-rs/creusot?tag=v0.9.0#fd089be0e96442f5e71605fbb129e6d6f7b264f3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f41572ae8e96468a0eb0411dbdf1defc4896287672041ca3c3c161c4e09c57d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -30,12 +30,19 @@ fn compute_chart_hash(ir: &Ir) -> u64 {
|
||||
for s in &ir.states {
|
||||
feed(&s.id.to_le_bytes());
|
||||
feed(s.name.to_string().as_bytes());
|
||||
feed(&[s.parent.unwrap_or(u16::MAX).to_le_bytes()[0], s.parent.unwrap_or(u16::MAX).to_le_bytes()[1]]);
|
||||
feed(&[
|
||||
s.parent.unwrap_or(u16::MAX).to_le_bytes()[0],
|
||||
s.parent.unwrap_or(u16::MAX).to_le_bytes()[1],
|
||||
]);
|
||||
feed(&s.depth.to_le_bytes());
|
||||
feed(&s.default_child.unwrap_or(u16::MAX).to_le_bytes());
|
||||
for &aid in &s.entries { feed(&aid.to_le_bytes()); }
|
||||
for &aid in &s.entries {
|
||||
feed(&aid.to_le_bytes());
|
||||
}
|
||||
feed(&[0xee]); // separator: entries → exits
|
||||
for &aid in &s.exits { feed(&aid.to_le_bytes()); }
|
||||
for &aid in &s.exits {
|
||||
feed(&aid.to_le_bytes());
|
||||
}
|
||||
feed(&[0xff]); // separator: exits → handlers
|
||||
for h_ir in &s.handlers {
|
||||
match &h_ir.trigger {
|
||||
@@ -63,7 +70,9 @@ fn compute_chart_hash(ir: &Ir) -> u64 {
|
||||
}
|
||||
}
|
||||
feed(&[0xfe]); // separator: handlers → timers
|
||||
for &t in &s.owned_timers { feed(&t.to_le_bytes()); }
|
||||
for &t in &s.owned_timers {
|
||||
feed(&t.to_le_bytes());
|
||||
}
|
||||
feed(&[0xfd]); // separator: timers → durings
|
||||
feed(&(s.durings.len() as u16).to_le_bytes());
|
||||
}
|
||||
|
||||
@@ -284,12 +284,6 @@ pub mod __private {
|
||||
pub fn clear(&mut self) {
|
||||
self.events.clear();
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.events.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.events.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "journal"))]
|
||||
|
||||
+270
-139
@@ -23,12 +23,12 @@ pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
LeafEv, // handled at leaf
|
||||
MidEv, // handled at Mid only
|
||||
RootEv, // handled at root only
|
||||
Unknown, // no handler anywhere
|
||||
Shadowed, // handler at both Leaf and Mid; leaf wins
|
||||
MultiAct, // multiple actions in same state for same trigger
|
||||
LeafEv, // handled at leaf
|
||||
MidEv, // handled at Mid only
|
||||
RootEv, // handled at root only
|
||||
Unknown, // no handler anywhere
|
||||
Shadowed, // handler at both Leaf and Mid; leaf wins
|
||||
MultiAct, // multiple actions in same state for same trigger
|
||||
Halt,
|
||||
}
|
||||
|
||||
@@ -61,18 +61,18 @@ statechart! {
|
||||
}
|
||||
|
||||
impl BubActions for BubActionContext<'_> {
|
||||
async fn m_in(&mut self) {}
|
||||
async fn m_out(&mut self) {}
|
||||
async fn l_in(&mut self) {}
|
||||
async fn l_out(&mut self) {}
|
||||
async fn root_handler(&mut self) {}
|
||||
async fn mid_handler(&mut self) {}
|
||||
async fn leaf_handler(&mut self) {}
|
||||
async fn mid_shadowed(&mut self) {}
|
||||
async fn m_in(&mut self) {}
|
||||
async fn m_out(&mut self) {}
|
||||
async fn l_in(&mut self) {}
|
||||
async fn l_out(&mut self) {}
|
||||
async fn root_handler(&mut self) {}
|
||||
async fn mid_handler(&mut self) {}
|
||||
async fn leaf_handler(&mut self) {}
|
||||
async fn mid_shadowed(&mut self) {}
|
||||
async fn leaf_shadowed(&mut self) {}
|
||||
async fn multi1(&mut self) {}
|
||||
async fn multi2(&mut self) {}
|
||||
async fn multi3(&mut self) {}
|
||||
async fn multi1(&mut self) {}
|
||||
async fn multi2(&mut self) {}
|
||||
async fn multi3(&mut self) {}
|
||||
}
|
||||
|
||||
const SR: u16 = 0;
|
||||
@@ -101,174 +101,305 @@ const E_LEAFEV: u16 = 3;
|
||||
const E_MULTIACT: u16 = 4;
|
||||
|
||||
fn handler_act(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Handler }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Handler,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_bub_handler_at_leaf() {
|
||||
// Leaf has handler for LeafEv. Bubbling stops at Leaf.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::LeafEv).await;
|
||||
let j = m.take_journal();
|
||||
// Find the EventDelivered for LeafEv.
|
||||
let delivered = j.iter().find(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_LEAFEV, .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
delivered,
|
||||
Some(TraceEvent::EventDelivered { handler_state: SL, event: E_LEAFEV })
|
||||
), "LeafEv must be delivered to Leaf, got: {:?}", delivered);
|
||||
// The handler action must fire.
|
||||
let saw_handler = j.iter().any(|e| matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked { state: SL, action: A_LEAF_HANDLER, kind: ActionKind::Handler }
|
||||
));
|
||||
assert!(saw_handler, "leaf_handler must run");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::LeafEv).await;
|
||||
let j = m.take_journal();
|
||||
// Find the EventDelivered for LeafEv.
|
||||
let delivered = j.iter().find(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered {
|
||||
event: E_LEAFEV,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
matches!(
|
||||
delivered,
|
||||
Some(TraceEvent::EventDelivered {
|
||||
handler_state: SL,
|
||||
event: E_LEAFEV
|
||||
})
|
||||
),
|
||||
"LeafEv must be delivered to Leaf, got: {:?}",
|
||||
delivered
|
||||
);
|
||||
// The handler action must fire.
|
||||
let saw_handler = j.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SL,
|
||||
action: A_LEAF_HANDLER,
|
||||
kind: ActionKind::Handler
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(saw_handler, "leaf_handler must run");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_bub_event_bubbles_to_parent() {
|
||||
// Leaf has no handler for MidEv. Bubble to Mid.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::MidEv).await;
|
||||
let j = m.take_journal();
|
||||
let delivered = j.iter().find(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_MIDEV, .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
delivered,
|
||||
Some(TraceEvent::EventDelivered { handler_state: SM, event: E_MIDEV })
|
||||
), "MidEv must be delivered to Mid, got: {:?}", delivered);
|
||||
assert_eq!(j.iter().filter(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { action: A_MID_HANDLER, .. }
|
||||
)).count(), 1);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::MidEv).await;
|
||||
let j = m.take_journal();
|
||||
let delivered = j
|
||||
.iter()
|
||||
.find(|e| matches!(e, TraceEvent::EventDelivered { event: E_MIDEV, .. }));
|
||||
assert!(
|
||||
matches!(
|
||||
delivered,
|
||||
Some(TraceEvent::EventDelivered {
|
||||
handler_state: SM,
|
||||
event: E_MIDEV
|
||||
})
|
||||
),
|
||||
"MidEv must be delivered to Mid, got: {:?}",
|
||||
delivered
|
||||
);
|
||||
assert_eq!(
|
||||
j.iter()
|
||||
.filter(|e| matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
action: A_MID_HANDLER,
|
||||
..
|
||||
}
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_bub_event_bubbles_to_root() {
|
||||
// Neither Leaf nor Mid handles RootEv. Bubble to Root.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::RootEv).await;
|
||||
let j = m.take_journal();
|
||||
let delivered = j.iter().find(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_ROOTEV, .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
delivered,
|
||||
Some(TraceEvent::EventDelivered { handler_state: SR, event: E_ROOTEV })
|
||||
), "RootEv must be delivered to Root, got: {:?}", delivered);
|
||||
assert_eq!(j.iter().filter(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { action: A_ROOT_HANDLER, .. }
|
||||
)).count(), 1);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::RootEv).await;
|
||||
let j = m.take_journal();
|
||||
let delivered = j.iter().find(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered {
|
||||
event: E_ROOTEV,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
matches!(
|
||||
delivered,
|
||||
Some(TraceEvent::EventDelivered {
|
||||
handler_state: SR,
|
||||
event: E_ROOTEV
|
||||
})
|
||||
),
|
||||
"RootEv must be delivered to Root, got: {:?}",
|
||||
delivered
|
||||
);
|
||||
assert_eq!(
|
||||
j.iter()
|
||||
.filter(|e| matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
action: A_ROOT_HANDLER,
|
||||
..
|
||||
}
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_bub_unknown_event_dropped() {
|
||||
// No state handles Unknown. Spec: "the event is silently discarded."
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let j = m.take_journal();
|
||||
// Must contain an EventDropped for Unknown.
|
||||
let dropped = j.iter().any(|e| matches!(e, TraceEvent::EventDropped { .. }));
|
||||
assert!(dropped, "unhandled Unknown must be journaled as EventDropped");
|
||||
// No EventDelivered for it.
|
||||
let delivered_count = j.iter().filter(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { .. }
|
||||
)).count();
|
||||
// No EventDelivered should fire at all (we only sent Unknown).
|
||||
assert_eq!(delivered_count, 0,
|
||||
"Unknown must not be delivered; got: {:?}", j);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let j = m.take_journal();
|
||||
// Must contain an EventDropped for Unknown.
|
||||
let dropped = j
|
||||
.iter()
|
||||
.any(|e| matches!(e, TraceEvent::EventDropped { .. }));
|
||||
assert!(
|
||||
dropped,
|
||||
"unhandled Unknown must be journaled as EventDropped"
|
||||
);
|
||||
// No EventDelivered for it.
|
||||
let delivered_count = j
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::EventDelivered { .. }))
|
||||
.count();
|
||||
// No EventDelivered should fire at all (we only sent Unknown).
|
||||
assert_eq!(
|
||||
delivered_count, 0,
|
||||
"Unknown must not be delivered; got: {:?}",
|
||||
j
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_bub_leaf_shadows_parent() {
|
||||
// Both Leaf and Mid have handlers for Shadowed. Leaf wins (search starts there).
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Shadowed).await;
|
||||
let j = m.take_journal();
|
||||
let leaf_fired = j.iter().any(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { action: A_LEAF_SHADOWED, .. }
|
||||
));
|
||||
let mid_fired = j.iter().any(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { action: A_MID_SHADOWED, .. }
|
||||
));
|
||||
assert!(leaf_fired, "leaf_shadowed must fire (innermost wins)");
|
||||
assert!(!mid_fired, "mid_shadowed must NOT fire (shadowed by leaf)");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Shadowed).await;
|
||||
let j = m.take_journal();
|
||||
let leaf_fired = j.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
action: A_LEAF_SHADOWED,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
let mid_fired = j.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
action: A_MID_SHADOWED,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(leaf_fired, "leaf_shadowed must fire (innermost wins)");
|
||||
assert!(!mid_fired, "mid_shadowed must NOT fire (shadowed by leaf)");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_bub_multiple_handlers_declaration_order() {
|
||||
// Leaf has three actions for MultiAct. They must fire in declaration order.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::MultiAct).await;
|
||||
let order: Vec<u16> = m.take_journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { action, kind: ActionKind::Handler, .. } => Some(*action),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(order, vec![A_MULTI1, A_MULTI2, A_MULTI3],
|
||||
"multi-action handlers must fire in declaration order");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::MultiAct).await;
|
||||
let order: Vec<u16> = m
|
||||
.take_journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
action,
|
||||
kind: ActionKind::Handler,
|
||||
..
|
||||
} => Some(*action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
order,
|
||||
vec![A_MULTI1, A_MULTI2, A_MULTI3],
|
||||
"multi-action handlers must fire in declaration order"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_bub_handler_state_recorded_correctly() {
|
||||
// EventDelivered.handler_state must reflect WHERE the handler matched, not
|
||||
// where the event was sent.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::MidEv).await;
|
||||
let _ = m.dispatch(Ev::RootEv).await;
|
||||
let j = m.take_journal();
|
||||
let mid_delivered = j.iter().find(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_MIDEV, .. }
|
||||
));
|
||||
let root_delivered = j.iter().find(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_ROOTEV, .. }
|
||||
));
|
||||
assert!(matches!(mid_delivered, Some(TraceEvent::EventDelivered { handler_state: SM, .. })));
|
||||
assert!(matches!(root_delivered, Some(TraceEvent::EventDelivered { handler_state: SR, .. })));
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::MidEv).await;
|
||||
let _ = m.dispatch(Ev::RootEv).await;
|
||||
let j = m.take_journal();
|
||||
let mid_delivered = j
|
||||
.iter()
|
||||
.find(|e| matches!(e, TraceEvent::EventDelivered { event: E_MIDEV, .. }));
|
||||
let root_delivered = j.iter().find(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered {
|
||||
event: E_ROOTEV,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(matches!(
|
||||
mid_delivered,
|
||||
Some(TraceEvent::EventDelivered {
|
||||
handler_state: SM,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
root_delivered,
|
||||
Some(TraceEvent::EventDelivered {
|
||||
handler_state: SR,
|
||||
..
|
||||
})
|
||||
));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_bub_multiple_unknown_events_each_dropped() {
|
||||
// Three unknowns — three EventDropped events.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let dropped_count = m.journal().iter().filter(|e| matches!(
|
||||
e, TraceEvent::EventDropped { .. }
|
||||
)).count();
|
||||
assert_eq!(dropped_count, 3);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let dropped_count = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::EventDropped { .. }))
|
||||
.count();
|
||||
assert_eq!(dropped_count, 3);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_bub_byte_deterministic_under_mixed_events() {
|
||||
let run = || async {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::LeafEv).await;
|
||||
let _ = m.dispatch(Ev::MidEv).await;
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let _ = m.dispatch(Ev::Shadowed).await;
|
||||
let _ = m.dispatch(Ev::MultiAct).await;
|
||||
let _ = m.dispatch(Ev::RootEv).await;
|
||||
m.take_journal()
|
||||
}).await
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Bub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::LeafEv).await;
|
||||
let _ = m.dispatch(Ev::MidEv).await;
|
||||
let _ = m.dispatch(Ev::Unknown).await;
|
||||
let _ = m.dispatch(Ev::Shadowed).await;
|
||||
let _ = m.dispatch(Ev::MultiAct).await;
|
||||
let _ = m.dispatch(Ev::RootEv).await;
|
||||
m.take_journal()
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = run().await;
|
||||
for i in 1..15 {
|
||||
|
||||
@@ -44,42 +44,51 @@ statechart! {
|
||||
}
|
||||
|
||||
impl CurActions for CurActionContext<'_> {
|
||||
async fn p_in(&mut self) {}
|
||||
async fn p_in(&mut self) {}
|
||||
async fn p_out(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_out(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_out(&mut self) {}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_current_state_initial_is_default_leaf() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Cur::new(Ctx);
|
||||
// Drive initial entry by sending an event.
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
// Now in B.
|
||||
assert_eq!(m.current_state(), CurState::B);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Cur::new(Ctx);
|
||||
// Drive initial entry by sending an event.
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
// Now in B.
|
||||
assert_eq!(m.current_state(), CurState::B);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_current_state_after_lateral_transition() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Cur::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
assert_eq!(m.current_state(), CurState::B);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Cur::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
assert_eq!(m.current_state(), CurState::B);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_current_state_after_up_returns_composite() {
|
||||
// Spec: current_state can return a composite if it became innermost via
|
||||
// an up-transition.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Cur::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToParent).await;
|
||||
assert_eq!(m.current_state(), CurState::Parent,
|
||||
"after up-transition, current_state returns the composite Parent");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Cur::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToParent).await;
|
||||
assert_eq!(
|
||||
m.current_state(),
|
||||
CurState::Parent,
|
||||
"after up-transition, current_state returns the composite Parent"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+137
-88
@@ -59,16 +59,16 @@ statechart! {
|
||||
}
|
||||
|
||||
impl DescActions for DescActionContext<'_> {
|
||||
async fn idle_in(&mut self) {}
|
||||
async fn idle_out(&mut self) {}
|
||||
async fn tower_in(&mut self) {}
|
||||
async fn idle_in(&mut self) {}
|
||||
async fn idle_out(&mut self) {}
|
||||
async fn tower_in(&mut self) {}
|
||||
async fn tower_out(&mut self) {}
|
||||
async fn l1_in(&mut self) {}
|
||||
async fn l1_out(&mut self) {}
|
||||
async fn l2_in(&mut self) {}
|
||||
async fn l2_out(&mut self) {}
|
||||
async fn l3_in(&mut self) {}
|
||||
async fn l3_out(&mut self) {}
|
||||
async fn l1_in(&mut self) {}
|
||||
async fn l1_out(&mut self) {}
|
||||
async fn l2_in(&mut self) {}
|
||||
async fn l2_out(&mut self) {}
|
||||
async fn l3_in(&mut self) {}
|
||||
async fn l3_out(&mut self) {}
|
||||
}
|
||||
|
||||
const SR: u16 = 0;
|
||||
@@ -90,104 +90,147 @@ const A_L3_OUT: u16 = 9;
|
||||
const E_GO: u16 = 0;
|
||||
|
||||
fn entry(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Entry }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Entry,
|
||||
}
|
||||
}
|
||||
fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Exit }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Exit,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_descent_target_with_three_default_levels() {
|
||||
// Idle → Tower: Tower has default chain L1 → L2 → L3.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let expected = vec![
|
||||
TraceEvent::Started { chart_hash: Desc::<8>::CHART_HASH },
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::Entered { state: SI },
|
||||
entry(SI, A_IDLE_IN),
|
||||
TraceEvent::EventDelivered { handler_state: SI, event: E_GO },
|
||||
TraceEvent::TransitionFired { from: Some(SI), to: ST },
|
||||
exit_(SI, A_IDLE_OUT),
|
||||
TraceEvent::Exited { state: SI },
|
||||
TraceEvent::Entered { state: ST },
|
||||
entry(ST, A_TOWER_IN),
|
||||
TraceEvent::Entered { state: SL1 },
|
||||
entry(SL1, A_L1_IN),
|
||||
TraceEvent::Entered { state: SL2 },
|
||||
entry(SL2, A_L2_IN),
|
||||
TraceEvent::Entered { state: SL3 },
|
||||
entry(SL3, A_L3_IN),
|
||||
];
|
||||
let expected = vec![
|
||||
TraceEvent::Started {
|
||||
chart_hash: Desc::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::Entered { state: SI },
|
||||
entry(SI, A_IDLE_IN),
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SI,
|
||||
event: E_GO,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SI),
|
||||
to: ST,
|
||||
},
|
||||
exit_(SI, A_IDLE_OUT),
|
||||
TraceEvent::Exited { state: SI },
|
||||
TraceEvent::Entered { state: ST },
|
||||
entry(ST, A_TOWER_IN),
|
||||
TraceEvent::Entered { state: SL1 },
|
||||
entry(SL1, A_L1_IN),
|
||||
TraceEvent::Entered { state: SL2 },
|
||||
entry(SL2, A_L2_IN),
|
||||
TraceEvent::Entered { state: SL3 },
|
||||
entry(SL3, A_L3_IN),
|
||||
];
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
}).await;
|
||||
assert_eq!(actual, expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_descent_entries_fire_outer_to_inner() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
// After the GO transition: order of Entered events should be Tower → L1 → L2 → L3.
|
||||
let split = m.journal().iter().position(|e| matches!(
|
||||
e, TraceEvent::TransitionFired { .. }
|
||||
)).unwrap();
|
||||
let entered: Vec<u16> = m.journal()[split..].iter().filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(entered, vec![ST, SL1, SL2, SL3]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
// After the GO transition: order of Entered events should be Tower → L1 → L2 → L3.
|
||||
let split = m
|
||||
.journal()
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.unwrap();
|
||||
let entered: Vec<u16> = m.journal()[split..]
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(entered, vec![ST, SL1, SL2, SL3]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_descent_termination_exits_full_chain() {
|
||||
// After full descent into L3, terminate exits L3 → L2 → L1 → Tower → Root.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exits: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(exits, vec![SI, SL3, SL2, SL1, ST, SR]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exits: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(exits, vec![SI, SL3, SL2, SL1, ST, SR]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_descent_action_kind_sequence() {
|
||||
// After GO: Tower entry kind, L1 entry, L2 entry, L3 entry. All Entry.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let split = m.journal().iter().position(|e| matches!(
|
||||
e, TraceEvent::TransitionFired { .. }
|
||||
)).unwrap();
|
||||
// After the Idle exit, all subsequent ActionInvoked must be Entry kind.
|
||||
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..].iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { kind, .. } => Some(*kind),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert!(kinds_after_exit.iter().all(|k| *k == ActionKind::Entry),
|
||||
"all post-transition actions must be Entry kind, got {:?}", kinds_after_exit);
|
||||
assert_eq!(kinds_after_exit.len(), 4, "Tower + L1 + L2 + L3 entries");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let split = m
|
||||
.journal()
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.unwrap();
|
||||
// After the Idle exit, all subsequent ActionInvoked must be Entry kind.
|
||||
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..]
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { kind, .. } => Some(*kind),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
kinds_after_exit.iter().all(|k| *k == ActionKind::Entry),
|
||||
"all post-transition actions must be Entry kind, got {:?}",
|
||||
kinds_after_exit
|
||||
);
|
||||
assert_eq!(kinds_after_exit.len(), 4, "Tower + L1 + L2 + L3 entries");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_descent_byte_deterministic() {
|
||||
let run = || async {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
}).await
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = run().await;
|
||||
for i in 1..10 {
|
||||
@@ -198,13 +241,19 @@ async fn det_descent_byte_deterministic() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_descent_initial_descent_is_idle_only() {
|
||||
// Initial default chain is Root → Idle. Idle has no children, so only one Entered.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let entered: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(entered, vec![SR, SI]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Desc::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let entered: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(entered, vec![SR, SI]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+139
-89
@@ -9,8 +9,8 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use core::time::Duration;
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx {
|
||||
@@ -72,128 +72,178 @@ const SI: u16 = 2;
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_during_started_after_entries() {
|
||||
// Spec: started AFTER entry actions of the owning state finish.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
// Find Working's entry action invocation.
|
||||
let w_entry_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { state: SW, kind: ActionKind::Entry, .. }
|
||||
));
|
||||
// Find first DuringStarted on Working.
|
||||
let d_started_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::DuringStarted { state: SW, .. }
|
||||
));
|
||||
assert!(w_entry_idx.is_some());
|
||||
assert!(d_started_idx.is_some());
|
||||
assert!(w_entry_idx.unwrap() < d_started_idx.unwrap(),
|
||||
"DuringStarted must come AFTER entry actions");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
// Find Working's entry action invocation.
|
||||
let w_entry_idx = j.iter().position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SW,
|
||||
kind: ActionKind::Entry,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
// Find first DuringStarted on Working.
|
||||
let d_started_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::DuringStarted { state: SW, .. }));
|
||||
assert!(w_entry_idx.is_some());
|
||||
assert!(d_started_idx.is_some());
|
||||
assert!(
|
||||
w_entry_idx.unwrap() < d_started_idx.unwrap(),
|
||||
"DuringStarted must come AFTER entry actions"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_during_cancelled_before_exits() {
|
||||
// Spec: cancelled BEFORE exit actions of the owning state begin.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let d_cancelled_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::DuringCancelled { state: SW, .. }
|
||||
));
|
||||
let w_exit_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { state: SW, kind: ActionKind::Exit, .. }
|
||||
));
|
||||
assert!(d_cancelled_idx.is_some(), "DuringCancelled must appear");
|
||||
assert!(w_exit_idx.is_some(), "exit actions must run");
|
||||
assert!(d_cancelled_idx.unwrap() < w_exit_idx.unwrap(),
|
||||
"DuringCancelled must come BEFORE exit actions");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let d_cancelled_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::DuringCancelled { state: SW, .. }));
|
||||
let w_exit_idx = j.iter().position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SW,
|
||||
kind: ActionKind::Exit,
|
||||
..
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(d_cancelled_idx.is_some(), "DuringCancelled must appear");
|
||||
assert!(w_exit_idx.is_some(), "exit actions must run");
|
||||
assert!(
|
||||
d_cancelled_idx.unwrap() < w_exit_idx.unwrap(),
|
||||
"DuringCancelled must come BEFORE exit actions"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_during_two_durings_two_started_two_cancelled() {
|
||||
// Two durings declared on Working — should see two DuringStarted and two DuringCancelled.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let started = j.iter().filter(|e| matches!(
|
||||
e, TraceEvent::DuringStarted { state: SW, .. }
|
||||
)).count();
|
||||
let cancelled = j.iter().filter(|e| matches!(
|
||||
e, TraceEvent::DuringCancelled { state: SW, .. }
|
||||
)).count();
|
||||
assert_eq!(started, 2);
|
||||
assert_eq!(cancelled, 2);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let started = j
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::DuringStarted { state: SW, .. }))
|
||||
.count();
|
||||
let cancelled = j
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::DuringCancelled { state: SW, .. }))
|
||||
.count();
|
||||
assert_eq!(started, 2);
|
||||
assert_eq!(cancelled, 2);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_during_indices_are_declaration_order() {
|
||||
// Indices 0, 1 in declaration order.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let started_indices: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::DuringStarted { during, .. } => Some(*during),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(started_indices, vec![0, 1]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let started_indices: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::DuringStarted { during, .. } => Some(*during),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(started_indices, vec![0, 1]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_during_cancelled_indices_match() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let cancelled_indices: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::DuringCancelled { during, .. } => Some(*during),
|
||||
_ => None,
|
||||
}).collect();
|
||||
// Cancellation also walks declaration order in the current implementation.
|
||||
assert_eq!(cancelled_indices, vec![0, 1]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let cancelled_indices: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::DuringCancelled { during, .. } => Some(*during),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Cancellation also walks declaration order in the current implementation.
|
||||
assert_eq!(cancelled_indices, vec![0, 1]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_during_state_id_matches() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let started_states: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::DuringStarted { state, .. } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
// Both DuringStarted events refer to Working (id 1).
|
||||
assert!(started_states.iter().all(|&s| s == SW));
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let started_states: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::DuringStarted { state, .. } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Both DuringStarted events refer to Working (id 1).
|
||||
assert!(started_states.iter().all(|&s| s == SW));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_during_no_durings_on_idle() {
|
||||
// Idle has no `during:` declarations. After we transition into Idle, no
|
||||
// DuringStarted for Idle.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Done).await;
|
||||
let on_idle: usize = m.journal().iter().filter(|e| matches!(
|
||||
e, TraceEvent::DuringStarted { state: SI, .. }
|
||||
)).count();
|
||||
assert_eq!(on_idle, 0);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Done).await;
|
||||
let on_idle: usize = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::DuringStarted { state: SI, .. }))
|
||||
.count();
|
||||
assert_eq!(on_idle, 0);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_during_byte_deterministic() {
|
||||
let run = || async {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Done).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
}).await
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Du::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Done).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = run().await;
|
||||
for i in 1..10 {
|
||||
|
||||
+137
-83
@@ -50,10 +50,10 @@ statechart! {
|
||||
}
|
||||
|
||||
impl EmActions for EmActionContext<'_> {
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_out(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_out(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_out(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_out(&mut self) {}
|
||||
async fn emit_followup(&mut self) {
|
||||
let _ = self.emit(Ev::Followup);
|
||||
}
|
||||
@@ -72,32 +72,41 @@ const E_MANY: u16 = 2;
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_emit_queues_event() {
|
||||
// emit_followup queues Followup. Journal must have EmitQueued.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let queued = m.journal().iter().filter(|e| matches!(
|
||||
e, TraceEvent::EmitQueued { .. }
|
||||
)).count();
|
||||
assert_eq!(queued, 1, "exactly one EmitQueued, got {:?}", m.journal());
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let queued = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::EmitQueued { .. }))
|
||||
.count();
|
||||
assert_eq!(queued, 1, "exactly one EmitQueued, got {:?}", m.journal());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_emit_followup_event_is_dispatched() {
|
||||
// After Trigger fires emit_followup, the queue contains Followup.
|
||||
// dispatch() drains, so Followup is dispatched and transitions A → B.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let j = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let j = m.take_journal();
|
||||
|
||||
// Both Trigger and Followup must be EventDelivered.
|
||||
let delivered_events: Vec<u16> = j.iter().filter_map(|e| match e {
|
||||
TraceEvent::EventDelivered { event, .. } => Some(*event),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(delivered_events, vec![E_TRIGGER, E_FOLLOWUP]);
|
||||
}).await;
|
||||
// Both Trigger and Followup must be EventDelivered.
|
||||
let delivered_events: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::EventDelivered { event, .. } => Some(*event),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(delivered_events, vec![E_TRIGGER, E_FOLLOWUP]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
@@ -108,62 +117,95 @@ async fn det_emit_no_reentrant_dispatch() {
|
||||
//
|
||||
// Verify: the EmitQueued event for Followup appears BEFORE the
|
||||
// EventDelivered for Followup.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let j = m.take_journal();
|
||||
let queued_idx = j.iter().position(|e| matches!(e, TraceEvent::EmitQueued { event: E_FOLLOWUP })).unwrap();
|
||||
let delivered_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_FOLLOWUP, .. }
|
||||
)).unwrap();
|
||||
assert!(queued_idx < delivered_idx,
|
||||
"EmitQueued ({}) must come before EventDelivered ({})", queued_idx, delivered_idx);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let j = m.take_journal();
|
||||
let queued_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::EmitQueued { event: E_FOLLOWUP }))
|
||||
.unwrap();
|
||||
let delivered_idx = j
|
||||
.iter()
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered {
|
||||
event: E_FOLLOWUP,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
queued_idx < delivered_idx,
|
||||
"EmitQueued ({}) must come before EventDelivered ({})",
|
||||
queued_idx,
|
||||
delivered_idx
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_emit_overflow_records_emitfailed() {
|
||||
// emit_many tries 15 emits. Default queue cap is 8 (one slot has Many in
|
||||
// it currently being processed; 7 more fit). Some succeed, some fail.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Many).await;
|
||||
let j = m.take_journal();
|
||||
let queued = j.iter().filter(|e| matches!(e, TraceEvent::EmitQueued { .. })).count();
|
||||
let failed = j.iter().filter(|e| matches!(e, TraceEvent::EmitFailed { .. })).count();
|
||||
assert!(queued > 0, "some emits must succeed");
|
||||
assert!(failed > 0, "some emits must fail when queue overflows");
|
||||
assert_eq!(queued + failed, 15, "total emit attempts must be 15");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Many).await;
|
||||
let j = m.take_journal();
|
||||
let queued = j
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::EmitQueued { .. }))
|
||||
.count();
|
||||
let failed = j
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::EmitFailed { .. }))
|
||||
.count();
|
||||
assert!(queued > 0, "some emits must succeed");
|
||||
assert!(failed > 0, "some emits must fail when queue overflows");
|
||||
assert_eq!(queued + failed, 15, "total emit attempts must be 15");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_emit_does_not_reorder_external_events() {
|
||||
// External events still process in arrival order. Emitted events are
|
||||
// appended to the queue (FIFO).
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let j = m.take_journal();
|
||||
let delivered_events: Vec<u16> = j.iter().filter_map(|e| match e {
|
||||
TraceEvent::EventDelivered { event, .. } => Some(*event),
|
||||
_ => None,
|
||||
}).collect();
|
||||
// Trigger was delivered first, then the emitted Followup.
|
||||
assert_eq!(delivered_events[0], E_TRIGGER);
|
||||
assert_eq!(delivered_events[1], E_FOLLOWUP);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let j = m.take_journal();
|
||||
let delivered_events: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::EventDelivered { event, .. } => Some(*event),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Trigger was delivered first, then the emitted Followup.
|
||||
assert_eq!(delivered_events[0], E_TRIGGER);
|
||||
assert_eq!(delivered_events[1], E_FOLLOWUP);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_emit_byte_deterministic() {
|
||||
let run = || async {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
}).await
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = run().await;
|
||||
for i in 1..10 {
|
||||
@@ -177,25 +219,37 @@ async fn det_emit_state_after_emit_action_runs_to_completion() {
|
||||
// Hard to test surgically without an action-after-emit; pin instead that
|
||||
// the EmitFailed events are followed by additional ActionInvoked events
|
||||
// for the SAME emit_many handler call.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Many).await;
|
||||
let j = m.take_journal();
|
||||
// The handler emit_many is one call; it emits 15 times. So we should see
|
||||
// 15 emit-result events all between two boundaries: after Many's
|
||||
// EventDelivered and before any subsequent Followup deliveries.
|
||||
let many_delivered_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_MANY, .. }
|
||||
)).unwrap();
|
||||
let emit_results: Vec<&TraceEvent> = j[many_delivered_idx..].iter().take_while(|e| matches!(
|
||||
e,
|
||||
TraceEvent::EmitQueued { .. }
|
||||
| TraceEvent::EmitFailed { .. }
|
||||
| TraceEvent::ActionInvoked { .. }
|
||||
| TraceEvent::EventDelivered { event: E_MANY, .. }
|
||||
)).filter(|e| matches!(
|
||||
e, TraceEvent::EmitQueued { .. } | TraceEvent::EmitFailed { .. }
|
||||
)).collect();
|
||||
assert_eq!(emit_results.len(), 15);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Em::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Many).await;
|
||||
let j = m.take_journal();
|
||||
// The handler emit_many is one call; it emits 15 times. So we should see
|
||||
// 15 emit-result events all between two boundaries: after Many's
|
||||
// EventDelivered and before any subsequent Followup deliveries.
|
||||
let many_delivered_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::EventDelivered { event: E_MANY, .. }))
|
||||
.unwrap();
|
||||
let emit_results: Vec<&TraceEvent> = j[many_delivered_idx..]
|
||||
.iter()
|
||||
.take_while(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EmitQueued { .. }
|
||||
| TraceEvent::EmitFailed { .. }
|
||||
| TraceEvent::ActionInvoked { .. }
|
||||
| TraceEvent::EventDelivered { event: E_MANY, .. }
|
||||
)
|
||||
})
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EmitQueued { .. } | TraceEvent::EmitFailed { .. }
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(emit_results.len(), 15);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -13,8 +13,8 @@ pub struct Ctx;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
Go,
|
||||
Cleanup, // emitted from A's exit action
|
||||
Wakeup, // emitted from B's entry action
|
||||
Cleanup, // emitted from A's exit action
|
||||
Wakeup, // emitted from B's entry action
|
||||
Halt,
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ statechart! {
|
||||
}
|
||||
|
||||
impl EtActions for EtActionContext<'_> {
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn b_out(&mut self) {}
|
||||
async fn emit_cleanup(&mut self) {
|
||||
let _ = self.emit(Ev::Cleanup);
|
||||
@@ -50,74 +50,89 @@ impl EtActions for EtActionContext<'_> {
|
||||
let _ = self.emit(Ev::Wakeup);
|
||||
}
|
||||
async fn log_cleanup(&mut self) {}
|
||||
async fn log_wakeup(&mut self) {}
|
||||
async fn log_wakeup(&mut self) {}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_emit_in_exit_action_runs_after_transition() {
|
||||
// Spec: emitted events are processed only after the current event has
|
||||
// fully finished — including all entry/exit actions.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Et::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let j = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Et::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let j = m.take_journal();
|
||||
|
||||
// Find indices of EmitQueued for Cleanup, EmitQueued for Wakeup, and
|
||||
// EventDelivered for each.
|
||||
// Event ids in interning order: Cleanup=0, Wakeup=1 (root handlers first),
|
||||
// Go=2 (A handler), Halt=3 (terminate at end of build).
|
||||
let cleanup_queued = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EmitQueued { event: 0 }
|
||||
));
|
||||
let wakeup_queued = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EmitQueued { event: 1 }
|
||||
));
|
||||
let cleanup_delivered = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: 0, .. }
|
||||
));
|
||||
let wakeup_delivered = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: 1, .. }
|
||||
));
|
||||
// Find indices of EmitQueued for Cleanup, EmitQueued for Wakeup, and
|
||||
// EventDelivered for each.
|
||||
// Event ids in interning order: Cleanup=0, Wakeup=1 (root handlers first),
|
||||
// Go=2 (A handler), Halt=3 (terminate at end of build).
|
||||
let cleanup_queued = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::EmitQueued { event: 0 }));
|
||||
let wakeup_queued = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::EmitQueued { event: 1 }));
|
||||
let cleanup_delivered = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::EventDelivered { event: 0, .. }));
|
||||
let wakeup_delivered = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::EventDelivered { event: 1, .. }));
|
||||
|
||||
assert!(cleanup_queued.is_some(), "Cleanup must be queued");
|
||||
assert!(wakeup_queued.is_some(), "Wakeup must be queued");
|
||||
assert!(cleanup_delivered.is_some(), "Cleanup must be delivered");
|
||||
assert!(wakeup_delivered.is_some(), "Wakeup must be delivered");
|
||||
assert!(cleanup_queued.is_some(), "Cleanup must be queued");
|
||||
assert!(wakeup_queued.is_some(), "Wakeup must be queued");
|
||||
assert!(cleanup_delivered.is_some(), "Cleanup must be delivered");
|
||||
assert!(wakeup_delivered.is_some(), "Wakeup must be delivered");
|
||||
|
||||
// Both emits queue BEFORE either is delivered (no re-entrant dispatch
|
||||
// inside the original Go transition).
|
||||
assert!(cleanup_queued.unwrap() < cleanup_delivered.unwrap());
|
||||
assert!(wakeup_queued.unwrap() < cleanup_delivered.unwrap(),
|
||||
"Wakeup queued before Cleanup delivered (transition completes first)");
|
||||
}).await;
|
||||
// Both emits queue BEFORE either is delivered (no re-entrant dispatch
|
||||
// inside the original Go transition).
|
||||
assert!(cleanup_queued.unwrap() < cleanup_delivered.unwrap());
|
||||
assert!(
|
||||
wakeup_queued.unwrap() < cleanup_delivered.unwrap(),
|
||||
"Wakeup queued before Cleanup delivered (transition completes first)"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_emit_in_exit_action_journals_in_order() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Et::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let j = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Et::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let j = m.take_journal();
|
||||
|
||||
// Both Cleanup and Wakeup were queued during the Go transition. They
|
||||
// are delivered AFTER. Order: Cleanup first (emitted from A's exit
|
||||
// which runs first), then Wakeup (emitted from B's entry).
|
||||
let queued_events: Vec<u16> = j.iter().filter_map(|e| match e {
|
||||
TraceEvent::EmitQueued { event } => Some(*event),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(queued_events, vec![0, 1], "Cleanup (id 0) then Wakeup (id 1)");
|
||||
}).await;
|
||||
// Both Cleanup and Wakeup were queued during the Go transition. They
|
||||
// are delivered AFTER. Order: Cleanup first (emitted from A's exit
|
||||
// which runs first), then Wakeup (emitted from B's entry).
|
||||
let queued_events: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::EmitQueued { event } => Some(*event),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
queued_events,
|
||||
vec![0, 1],
|
||||
"Cleanup (id 0) then Wakeup (id 1)"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_emit_in_transition_byte_deterministic() {
|
||||
let run = || async {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Et::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
m.take_journal()
|
||||
}).await
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Et::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
m.take_journal()
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = run().await;
|
||||
for i in 1..10 {
|
||||
|
||||
+161
-101
@@ -64,15 +64,15 @@ statechart! {
|
||||
}
|
||||
|
||||
impl Init4Actions for Init4ActionContext<'_> {
|
||||
async fn r_in(&mut self) {}
|
||||
async fn r_in(&mut self) {}
|
||||
async fn r_out(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_out(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_out(&mut self) {}
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_out(&mut self) {}
|
||||
async fn d_in(&mut self) {}
|
||||
async fn d_in(&mut self) {}
|
||||
async fn d_out(&mut self) {}
|
||||
}
|
||||
|
||||
@@ -98,70 +98,86 @@ const A_D_OUT: u16 = 9;
|
||||
const E_HALT: u16 = 0;
|
||||
|
||||
fn entry(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Entry }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Entry,
|
||||
}
|
||||
}
|
||||
fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Exit }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Exit,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_initial_descent_records_full_path() {
|
||||
// Spec: "the root is always active" + default-descent unrolls Root → A → B → C → D.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
// Push Halt to drive the chart through initial entry then termination.
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
// Push Halt to drive the chart through initial entry then termination.
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
|
||||
let actual = m.take_journal();
|
||||
let expected = vec![
|
||||
// Initial enter (driven by dispatch's prime-state branch).
|
||||
TraceEvent::Started { chart_hash: Init4::<8>::CHART_HASH },
|
||||
TraceEvent::Entered { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
// Halt.
|
||||
TraceEvent::TerminateRequested { event: E_HALT },
|
||||
// Bottom-up exit chain.
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
exit_(SC, A_C_OUT),
|
||||
TraceEvent::Exited { state: SC },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
exit_(SR, A_R_OUT),
|
||||
TraceEvent::Exited { state: SR },
|
||||
TraceEvent::Terminated,
|
||||
];
|
||||
let actual = m.take_journal();
|
||||
let expected = vec![
|
||||
// Initial enter (driven by dispatch's prime-state branch).
|
||||
TraceEvent::Started {
|
||||
chart_hash: Init4::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::Entered { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
TraceEvent::Entered { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
// Halt.
|
||||
TraceEvent::TerminateRequested { event: E_HALT },
|
||||
// Bottom-up exit chain.
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
exit_(SC, A_C_OUT),
|
||||
TraceEvent::Exited { state: SC },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
exit_(SR, A_R_OUT),
|
||||
TraceEvent::Exited { state: SR },
|
||||
TraceEvent::Terminated,
|
||||
];
|
||||
|
||||
let mismatch = actual.iter().zip(expected.iter())
|
||||
.position(|(a, e)| a != e)
|
||||
.map(|i| i.to_string())
|
||||
.unwrap_or_else(|| "<length>".to_string());
|
||||
assert_eq!(actual, expected, "first mismatch at index {mismatch}");
|
||||
}).await;
|
||||
let mismatch = actual
|
||||
.iter()
|
||||
.zip(expected.iter())
|
||||
.position(|(a, e)| a != e)
|
||||
.map(|i| i.to_string())
|
||||
.unwrap_or_else(|| "<length>".to_string());
|
||||
assert_eq!(actual, expected, "first mismatch at index {mismatch}");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_started_is_first_event() {
|
||||
// Spec: every journal begins with Started carrying the chart hash.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.journal();
|
||||
assert!(matches!(j[0], TraceEvent::Started { .. }));
|
||||
if let TraceEvent::Started { chart_hash } = j[0] {
|
||||
assert_eq!(chart_hash, Init4::<8>::CHART_HASH);
|
||||
}
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.journal();
|
||||
assert!(matches!(j[0], TraceEvent::Started { .. }));
|
||||
if let TraceEvent::Started { chart_hash } = j[0] {
|
||||
assert_eq!(chart_hash, Init4::<8>::CHART_HASH);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
@@ -174,68 +190,110 @@ async fn det_chart_hash_nonzero() {
|
||||
async fn det_root_state_id_is_zero() {
|
||||
// Spec: "the root state (the machine itself) is just a state". It's
|
||||
// allocated first and so gets id 0; tests below depend on this.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
// First Entered event must be state 0.
|
||||
let first_entered = m.journal().iter().find(|e| matches!(e, TraceEvent::Entered { .. }));
|
||||
assert!(matches!(first_entered, Some(TraceEvent::Entered { state: 0 })));
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
// First Entered event must be state 0.
|
||||
let first_entered = m
|
||||
.journal()
|
||||
.iter()
|
||||
.find(|e| matches!(e, TraceEvent::Entered { .. }));
|
||||
assert!(matches!(
|
||||
first_entered,
|
||||
Some(TraceEvent::Entered { state: 0 })
|
||||
));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_entry_actions_fire_outer_to_inner() {
|
||||
// Spec: "Entry runs outer-to-inner."
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let entered: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { state, kind: ActionKind::Entry, .. } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(entered, vec![SR, SA, SB, SC, SD],
|
||||
"entry actions must fire outer-to-inner across hierarchy");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let entered: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
kind: ActionKind::Entry,
|
||||
..
|
||||
} => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entered,
|
||||
vec![SR, SA, SB, SC, SD],
|
||||
"entry actions must fire outer-to-inner across hierarchy"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_exit_actions_fire_inner_to_outer() {
|
||||
// Spec: "Exit runs inner-to-outer."
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exited: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { state, kind: ActionKind::Exit, .. } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(exited, vec![SD, SC, SB, SA, SR],
|
||||
"exit actions must fire inner-to-outer across hierarchy");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exited: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
kind: ActionKind::Exit,
|
||||
..
|
||||
} => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
exited,
|
||||
vec![SD, SC, SB, SA, SR],
|
||||
"exit actions must fire inner-to-outer across hierarchy"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_state_ids_are_declaration_order() {
|
||||
// Spec: state ids assigned in parse order (0=root, then nested).
|
||||
// Verify by checking the order Entered events appear.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let entered_ids: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(entered_ids, vec![SR, SA, SB, SC, SD]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let entered_ids: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(entered_ids, vec![SR, SA, SB, SC, SD]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_initial_descent_byte_deterministic_across_runs() {
|
||||
let run = || async {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
}).await
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = run().await;
|
||||
for i in 1..20 {
|
||||
@@ -246,9 +304,11 @@ async fn det_initial_descent_byte_deterministic_across_runs() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminated_is_last_event() {
|
||||
// Spec: "Always the final event in the journal."
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
assert!(matches!(m.journal().last(), Some(TraceEvent::Terminated)));
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Init4::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
assert!(matches!(m.journal().last(), Some(TraceEvent::Terminated)));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+272
-167
@@ -75,17 +75,17 @@ statechart! {
|
||||
}
|
||||
|
||||
impl LatActions for LatActionContext<'_> {
|
||||
async fn p_in(&mut self) {}
|
||||
async fn p_in(&mut self) {}
|
||||
async fn p_out(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_out(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_out(&mut self) {}
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_out(&mut self) {}
|
||||
async fn d_in(&mut self) {}
|
||||
async fn d_in(&mut self) {}
|
||||
async fn d_out(&mut self) {}
|
||||
async fn e_in(&mut self) {}
|
||||
async fn e_in(&mut self) {}
|
||||
async fn e_out(&mut self) {}
|
||||
}
|
||||
|
||||
@@ -118,20 +118,34 @@ const E_GOD: u16 = 2;
|
||||
const E_GOE: u16 = 3;
|
||||
|
||||
fn entry(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Entry }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Entry,
|
||||
}
|
||||
}
|
||||
fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Exit }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Exit,
|
||||
}
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
fn handler(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Handler }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Handler,
|
||||
}
|
||||
}
|
||||
|
||||
// Initial descent: Root → Parent → A.
|
||||
fn initial_descent() -> Vec<TraceEvent> {
|
||||
vec![
|
||||
TraceEvent::Started { chart_hash: Lat::<8>::CHART_HASH },
|
||||
TraceEvent::Started {
|
||||
chart_hash: Lat::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::Entered { state: SP },
|
||||
entry(SP, A_P_IN),
|
||||
@@ -142,227 +156,318 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
|
||||
// Compare actual vs expected and report first mismatch.
|
||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||
let mismatch = actual.iter().zip(expected.iter())
|
||||
.position(|(a, e)| a != e);
|
||||
let mismatch = actual.iter().zip(expected.iter()).position(|(a, e)| a != e);
|
||||
if let Some(i) = mismatch {
|
||||
panic!(
|
||||
"journal mismatch at index {}\n actual: {:?}\n expected: {:?}\n full actual: {:#?}",
|
||||
i, actual[i], expected[i], actual
|
||||
);
|
||||
}
|
||||
assert_eq!(actual.len(), expected.len(),
|
||||
assert_eq!(
|
||||
actual.len(),
|
||||
expected.len(),
|
||||
"journal length mismatch: actual {} vs expected {}\n full actual: {:#?}",
|
||||
actual.len(), expected.len(), actual);
|
||||
actual.len(),
|
||||
expected.len(),
|
||||
actual
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_a_to_b_lca_is_parent() {
|
||||
// Spec: A → B (siblings under Parent). LCA = Parent. Parent is not exited
|
||||
// or re-entered.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SA, event: E_GOB },
|
||||
TraceEvent::TransitionFired { from: Some(SA), to: SB },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::Entered { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOB,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::Entered { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_a_to_c_exits_parent() {
|
||||
// A → C: A is inside Parent, C is sibling of Parent. LCA = Root.
|
||||
// Exit A, exit Parent. Enter C.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoC).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoC).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SA, event: E_GOC },
|
||||
TraceEvent::TransitionFired { from: Some(SA), to: SC },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOC,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SC,
|
||||
},
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_target_with_children_default_descends() {
|
||||
// A → D: D has child E (default). After entering D, default-descend to E.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoD).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoD).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SA, event: E_GOD },
|
||||
TraceEvent::TransitionFired { from: Some(SA), to: SD },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
entry(SE, A_E_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOD,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SD,
|
||||
},
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
entry(SE, A_E_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_to_deeply_nested_target() {
|
||||
// A → E (E is the leaf of D's default chain). The target itself is the leaf
|
||||
// so default-descent doesn't add another level — but D is entered along the way.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoE).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoE).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SA, event: E_GOE },
|
||||
TraceEvent::TransitionFired { from: Some(SA), to: SE },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
entry(SE, A_E_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOE,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SE,
|
||||
},
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SD },
|
||||
entry(SD, A_D_IN),
|
||||
TraceEvent::Entered { state: SE },
|
||||
entry(SE, A_E_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_chained_transitions() {
|
||||
// A → B → C: two consecutive transitions. Each independently records its
|
||||
// exits and entries.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
let _ = m.dispatch(Ev::GoC).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
let _ = m.dispatch(Ev::GoC).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let mut expected = initial_descent();
|
||||
// A → B
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SA, event: E_GOB },
|
||||
TraceEvent::TransitionFired { from: Some(SA), to: SB },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::Entered { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
]);
|
||||
// B → C (LCA = Root, so Parent exits too).
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SB, event: E_GOC },
|
||||
TraceEvent::TransitionFired { from: Some(SB), to: SC },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
let mut expected = initial_descent();
|
||||
// A → B
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOB,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SA),
|
||||
to: SB,
|
||||
},
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
TraceEvent::Entered { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
]);
|
||||
// B → C (LCA = Root, so Parent exits too).
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SB,
|
||||
event: E_GOC,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SC,
|
||||
},
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
exit_(SP, A_P_OUT),
|
||||
TraceEvent::Exited { state: SP },
|
||||
TraceEvent::Entered { state: SC },
|
||||
entry(SC, A_C_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_lca_stays_active() {
|
||||
// A → B: Parent (the LCA) must NOT appear in any Exited or Entered event.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
let j = m.take_journal();
|
||||
// After initial Entered(Parent), Parent must not appear again.
|
||||
let parent_events: Vec<&TraceEvent> = j.iter().skip(3).filter(|e| matches!(
|
||||
e,
|
||||
TraceEvent::Entered { state: SP } | TraceEvent::Exited { state: SP }
|
||||
)).collect();
|
||||
assert!(parent_events.is_empty(),
|
||||
"LCA Parent must not be re-entered or exited; got: {:?}", parent_events);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
let j = m.take_journal();
|
||||
// After initial Entered(Parent), Parent must not appear again.
|
||||
let parent_events: Vec<&TraceEvent> = j
|
||||
.iter()
|
||||
.skip(3)
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::Entered { state: SP } | TraceEvent::Exited { state: SP }
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
parent_events.is_empty(),
|
||||
"LCA Parent must not be re-entered or exited; got: {:?}",
|
||||
parent_events
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_exit_inner_then_outer() {
|
||||
// A → C: exit A first (deeper), then Parent (outer).
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoC).await;
|
||||
let exits: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
// First exit must be A (deeper), then Parent (outer).
|
||||
assert_eq!(&exits[..2], &[SA, SP], "exits must be inner-to-outer");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoC).await;
|
||||
let exits: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// First exit must be A (deeper), then Parent (outer).
|
||||
assert_eq!(&exits[..2], &[SA, SP], "exits must be inner-to-outer");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_entry_outer_then_inner() {
|
||||
// A → D where D has child E: enter D first, then E.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoD).await;
|
||||
let entries: Vec<u16> = m.journal().iter().skip_while(|e| !matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired { .. }
|
||||
)).filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(&entries, &[SD, SE], "entries on the way down must be outer-to-inner");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoD).await;
|
||||
let entries: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.skip_while(|e| !matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
&entries,
|
||||
&[SD, SE],
|
||||
"entries on the way down must be outer-to-inner"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_no_default_when_target_is_leaf() {
|
||||
// A → C where C is a leaf: no default-descent, journal stops at C.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoC).await;
|
||||
let last_entered: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
// Initial: Root, Parent, A. After GoC: C. No further entries.
|
||||
assert_eq!(last_entered, vec![SR, SP, SA, SC]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoC).await;
|
||||
let last_entered: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// Initial: Root, Parent, A. After GoC: C. No further entries.
|
||||
assert_eq!(last_entered, vec![SR, SP, SA, SC]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_lateral_event_delivered_records_handler_state() {
|
||||
// The handler for GoB lives on A. EventDelivered.handler_state = A.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
let delivered = m.journal().iter().find(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_GOB, .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
delivered,
|
||||
Some(TraceEvent::EventDelivered { handler_state: SA, event: E_GOB })
|
||||
), "got: {:?}", delivered);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Lat::new(Ctx);
|
||||
let _ = m.dispatch(Ev::GoB).await;
|
||||
let delivered = m
|
||||
.journal()
|
||||
.iter()
|
||||
.find(|e| matches!(e, TraceEvent::EventDelivered { event: E_GOB, .. }));
|
||||
assert!(
|
||||
matches!(
|
||||
delivered,
|
||||
Some(TraceEvent::EventDelivered {
|
||||
handler_state: SA,
|
||||
event: E_GOB
|
||||
})
|
||||
),
|
||||
"got: {:?}",
|
||||
delivered
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+196
-109
@@ -52,17 +52,17 @@ statechart! {
|
||||
}
|
||||
|
||||
impl OrdActions for OrdActionContext<'_> {
|
||||
async fn r1(&mut self) {}
|
||||
async fn r2(&mut self) {}
|
||||
async fn r3(&mut self) {}
|
||||
async fn r1(&mut self) {}
|
||||
async fn r2(&mut self) {}
|
||||
async fn r3(&mut self) {}
|
||||
async fn r_x1(&mut self) {}
|
||||
async fn r_x2(&mut self) {}
|
||||
async fn a1(&mut self) {}
|
||||
async fn a2(&mut self) {}
|
||||
async fn a1(&mut self) {}
|
||||
async fn a2(&mut self) {}
|
||||
async fn a_x1(&mut self) {}
|
||||
async fn a_x2(&mut self) {}
|
||||
async fn a_x3(&mut self) {}
|
||||
async fn b1(&mut self) {}
|
||||
async fn b1(&mut self) {}
|
||||
async fn b_x1(&mut self) {}
|
||||
async fn act1(&mut self) {}
|
||||
async fn act2(&mut self) {}
|
||||
@@ -95,141 +95,228 @@ const A_BX1: u16 = 14;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_ord_root_entries_in_declaration_order() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
// Filter to root entry actions in journal order.
|
||||
let r_entries: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { state: SR, action, kind: ActionKind::Entry } => Some(*action),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(r_entries, vec![A_R1, A_R2, A_R3]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
// Filter to root entry actions in journal order.
|
||||
let r_entries: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SR,
|
||||
action,
|
||||
kind: ActionKind::Entry,
|
||||
} => Some(*action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(r_entries, vec![A_R1, A_R2, A_R3]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_ord_root_exits_in_declaration_order() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let r_exits: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { state: SR, action, kind: ActionKind::Exit } => Some(*action),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(r_exits, vec![A_RX1, A_RX2]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let r_exits: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SR,
|
||||
action,
|
||||
kind: ActionKind::Exit,
|
||||
} => Some(*action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(r_exits, vec![A_RX1, A_RX2]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_ord_state_a_entries_in_declaration_order() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let a_entries: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { state: SA, action, kind: ActionKind::Entry } => Some(*action),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(a_entries, vec![A_A1, A_A2]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let a_entries: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SA,
|
||||
action,
|
||||
kind: ActionKind::Entry,
|
||||
} => Some(*action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(a_entries, vec![A_A1, A_A2]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_ord_state_a_exits_in_declaration_order() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let a_exits: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { state: SA, action, kind: ActionKind::Exit } => Some(*action),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(a_exits, vec![A_AX1, A_AX2, A_AX3]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let a_exits: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SA,
|
||||
action,
|
||||
kind: ActionKind::Exit,
|
||||
} => Some(*action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(a_exits, vec![A_AX1, A_AX2, A_AX3]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_ord_entry_outer_then_inner_across_hierarchy() {
|
||||
// Initial entry: Root → A. Root's entries fire first, then A's.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let entries_in_order: Vec<u16> = m.journal().iter().take_while(|e| !matches!(
|
||||
e, TraceEvent::TerminateRequested { .. }
|
||||
)).filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { action, kind: ActionKind::Entry, .. } => Some(*action),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(entries_in_order, vec![A_R1, A_R2, A_R3, A_A1, A_A2]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let entries_in_order: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.take_while(|e| !matches!(e, TraceEvent::TerminateRequested { .. }))
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
action,
|
||||
kind: ActionKind::Entry,
|
||||
..
|
||||
} => Some(*action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(entries_in_order, vec![A_R1, A_R2, A_R3, A_A1, A_A2]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_ord_exit_inner_then_outer_across_hierarchy() {
|
||||
// On terminate: A's exits then Root's exits.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exits_in_order: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { action, kind: ActionKind::Exit, .. } => Some(*action),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(exits_in_order, vec![A_AX1, A_AX2, A_AX3, A_RX1, A_RX2]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exits_in_order: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
action,
|
||||
kind: ActionKind::Exit,
|
||||
..
|
||||
} => Some(*action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(exits_in_order, vec![A_AX1, A_AX2, A_AX3, A_RX1, A_RX2]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_ord_actions_then_transition_on_same_trigger() {
|
||||
// Multi: act1, act2, act3 fire in declaration order, then transition to B.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Multi).await;
|
||||
let j = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Multi).await;
|
||||
let j = m.take_journal();
|
||||
|
||||
// Find the action invocations and the TransitionFired in order.
|
||||
let after_delivered: Vec<&TraceEvent> = j.iter()
|
||||
.skip_while(|e| !matches!(e, TraceEvent::EventDelivered { event: 1, .. }))
|
||||
// Multi is the second event: Go=0, Multi=1.
|
||||
.skip(1)
|
||||
.take(20)
|
||||
.collect();
|
||||
// First three should be Handler actions in order act1, act2, act3.
|
||||
let handler_actions: Vec<u16> = after_delivered.iter().take_while(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { kind: ActionKind::Handler, .. }
|
||||
)).filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { action, .. } => Some(*action),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(handler_actions, vec![A_ACT1, A_ACT2, A_ACT3],
|
||||
"actions in declaration order");
|
||||
// After the actions, a TransitionFired must follow.
|
||||
let trans_idx = after_delivered.iter().position(|e| matches!(
|
||||
e, TraceEvent::TransitionFired { .. }
|
||||
)).unwrap();
|
||||
assert_eq!(trans_idx, 3, "transition fires after the 3 actions");
|
||||
}).await;
|
||||
// Find the action invocations and the TransitionFired in order.
|
||||
let after_delivered: Vec<&TraceEvent> = j
|
||||
.iter()
|
||||
.skip_while(|e| !matches!(e, TraceEvent::EventDelivered { event: 1, .. }))
|
||||
// Multi is the second event: Go=0, Multi=1.
|
||||
.skip(1)
|
||||
.take(20)
|
||||
.collect();
|
||||
// First three should be Handler actions in order act1, act2, act3.
|
||||
let handler_actions: Vec<u16> = after_delivered
|
||||
.iter()
|
||||
.take_while(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
kind: ActionKind::Handler,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { action, .. } => Some(*action),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
handler_actions,
|
||||
vec![A_ACT1, A_ACT2, A_ACT3],
|
||||
"actions in declaration order"
|
||||
);
|
||||
// After the actions, a TransitionFired must follow.
|
||||
let trans_idx = after_delivered
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.unwrap();
|
||||
assert_eq!(trans_idx, 3, "transition fires after the 3 actions");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_ord_full_transition_canonical_sequence() {
|
||||
// Spec: cancel durings → run exits inner→outer → run entries outer→inner → start durings.
|
||||
// No durings here, so just exits inner→outer + entries outer→inner.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let j = m.take_journal();
|
||||
// Find the TransitionFired and capture everything until A's exit and B's entry are done.
|
||||
let trans_idx = j.iter().position(|e| matches!(e, TraceEvent::TransitionFired { .. })).unwrap();
|
||||
let kinds: Vec<ActionKind> = j[trans_idx..].iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { kind, .. } => Some(*kind),
|
||||
_ => None,
|
||||
}).collect();
|
||||
// First A's exit kinds (inner-to-outer order — A is innermost), then B's entry kinds.
|
||||
let exit_count = kinds.iter().filter(|k| **k == ActionKind::Exit).count();
|
||||
let entry_count = kinds.iter().filter(|k| **k == ActionKind::Entry).count();
|
||||
assert_eq!(exit_count, 3, "A has 3 exit actions");
|
||||
assert_eq!(entry_count, 1, "B has 1 entry action");
|
||||
// Exits before entries.
|
||||
let first_entry = kinds.iter().position(|k| *k == ActionKind::Entry).unwrap();
|
||||
let last_exit = kinds.iter().rposition(|k| *k == ActionKind::Exit).unwrap();
|
||||
assert!(last_exit < first_entry, "all exits must precede all entries in a transition");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Ord::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let j = m.take_journal();
|
||||
// Find the TransitionFired and capture everything until A's exit and B's entry are done.
|
||||
let trans_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.unwrap();
|
||||
let kinds: Vec<ActionKind> = j[trans_idx..]
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { kind, .. } => Some(*kind),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// First A's exit kinds (inner-to-outer order — A is innermost), then B's entry kinds.
|
||||
let exit_count = kinds.iter().filter(|k| **k == ActionKind::Exit).count();
|
||||
let entry_count = kinds.iter().filter(|k| **k == ActionKind::Entry).count();
|
||||
assert_eq!(exit_count, 3, "A has 3 exit actions");
|
||||
assert_eq!(entry_count, 1, "B has 1 entry action");
|
||||
// Exits before entries.
|
||||
let first_entry = kinds.iter().position(|k| *k == ActionKind::Entry).unwrap();
|
||||
let last_exit = kinds.iter().rposition(|k| *k == ActionKind::Exit).unwrap();
|
||||
assert!(
|
||||
last_exit < first_entry,
|
||||
"all exits must precede all entries in a transition"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -52,11 +52,11 @@ statechart! {
|
||||
}
|
||||
|
||||
impl SubActions for SubActionContext<'_> {
|
||||
async fn r_in(&mut self) {}
|
||||
async fn r_in(&mut self) {}
|
||||
async fn r_out(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_out(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_out(&mut self) {}
|
||||
}
|
||||
|
||||
@@ -72,71 +72,111 @@ const A_B_OUT: u16 = 5;
|
||||
const E_UPTOROOT: u16 = 0;
|
||||
|
||||
fn entry(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Entry }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Entry,
|
||||
}
|
||||
}
|
||||
fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Exit }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Exit,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_root_targetable_by_chart_name() {
|
||||
// Chart compiles. Targeting root by chart name resolves correctly.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Sub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Sub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
// Up-transition to root from B: exit B and A. Root is target,
|
||||
// not exited, not re-entered, no default-descent.
|
||||
let expected = vec![
|
||||
TraceEvent::Started { chart_hash: Sub::<8>::CHART_HASH },
|
||||
TraceEvent::Entered { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::EventDelivered { handler_state: SB, event: E_UPTOROOT },
|
||||
TraceEvent::TransitionFired { from: Some(SB), to: SR },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
];
|
||||
// Up-transition to root from B: exit B and A. Root is target,
|
||||
// not exited, not re-entered, no default-descent.
|
||||
let expected = vec![
|
||||
TraceEvent::Started {
|
||||
chart_hash: Sub::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
entry(SA, A_A_IN),
|
||||
TraceEvent::Entered { state: SB },
|
||||
entry(SB, A_B_IN),
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SB,
|
||||
event: E_UPTOROOT,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SB),
|
||||
to: SR,
|
||||
},
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
exit_(SA, A_A_OUT),
|
||||
TraceEvent::Exited { state: SA },
|
||||
];
|
||||
|
||||
assert_eq!(actual, expected, "root targetable; up-transition to root exits A and B only");
|
||||
}).await;
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"root targetable; up-transition to root exits A and B only"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_root_target_does_not_re_enter_root() {
|
||||
// After the up-to-root, root must NOT have re-entered.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Sub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||
let j = m.take_journal();
|
||||
// After the TransitionFired, no Entered event should fire.
|
||||
let split = j.iter().position(|e| matches!(e, TraceEvent::TransitionFired { .. })).unwrap();
|
||||
let entered_after = j[split..].iter().filter(|e| matches!(e, TraceEvent::Entered { .. })).count();
|
||||
assert_eq!(entered_after, 0, "up-to-root must not re-enter root or default-descend");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Sub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||
let j = m.take_journal();
|
||||
// After the TransitionFired, no Entered event should fire.
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.unwrap();
|
||||
let entered_after = j[split..]
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::Entered { .. }))
|
||||
.count();
|
||||
assert_eq!(
|
||||
entered_after, 0,
|
||||
"up-to-root must not re-enter root or default-descend"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_root_target_then_terminate() {
|
||||
// After up-to-root, root is the innermost active state. Terminate exits root only.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Sub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
// After UpToRoot we're in root only. Halt exits root only.
|
||||
let exits: Vec<u16> = j.iter().filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(exits, vec![SB, SA, SR],
|
||||
"up-to-root exits B, A; terminate exits root");
|
||||
assert!(matches!(j.last(), Some(TraceEvent::Terminated)));
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Sub::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToRoot).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
// After UpToRoot we're in root only. Halt exits root only.
|
||||
let exits: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
exits,
|
||||
vec![SB, SA, SR],
|
||||
"up-to-root exits B, A; terminate exits root"
|
||||
);
|
||||
assert!(matches!(j.last(), Some(TraceEvent::Terminated)));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+183
-94
@@ -49,9 +49,9 @@ statechart! {
|
||||
}
|
||||
|
||||
impl Self_Actions for Self_ActionContext<'_> {
|
||||
async fn m_in(&mut self) {}
|
||||
async fn m_in(&mut self) {}
|
||||
async fn m_out(&mut self) {}
|
||||
async fn l_in(&mut self) {}
|
||||
async fn l_in(&mut self) {}
|
||||
async fn l_out(&mut self) {}
|
||||
}
|
||||
|
||||
@@ -68,14 +68,24 @@ const E_SELFMID: u16 = 0;
|
||||
const E_SELFLEAF: u16 = 1;
|
||||
|
||||
fn entry(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Entry }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Entry,
|
||||
}
|
||||
}
|
||||
fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Exit }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Exit,
|
||||
}
|
||||
}
|
||||
fn initial_descent() -> Vec<TraceEvent> {
|
||||
vec![
|
||||
TraceEvent::Started { chart_hash: Self_::<8>::CHART_HASH },
|
||||
TraceEvent::Started {
|
||||
chart_hash: Self_::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: SR },
|
||||
TraceEvent::Entered { state: SM },
|
||||
entry(SM, A_M_IN),
|
||||
@@ -84,60 +94,92 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
]
|
||||
}
|
||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||
let mismatch = actual.iter().zip(expected.iter())
|
||||
.position(|(a, e)| a != e);
|
||||
let mismatch = actual.iter().zip(expected.iter()).position(|(a, e)| a != e);
|
||||
if let Some(i) = mismatch {
|
||||
panic!("mismatch at index {}\n actual: {:?}\n expected: {:?}",
|
||||
i, actual.get(i), expected.get(i));
|
||||
panic!(
|
||||
"mismatch at index {}\n actual: {:?}\n expected: {:?}",
|
||||
i,
|
||||
actual.get(i),
|
||||
expected.get(i)
|
||||
);
|
||||
}
|
||||
assert_eq!(actual.len(), expected.len(),
|
||||
"length mismatch: actual {} vs expected {}", actual.len(), expected.len());
|
||||
assert_eq!(
|
||||
actual.len(),
|
||||
expected.len(),
|
||||
"length mismatch: actual {} vs expected {}",
|
||||
actual.len(),
|
||||
expected.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_self_leaf_exits_and_reenters_only_leaf() {
|
||||
// Spec: nothing above the target is touched.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SL, event: E_SELFLEAF },
|
||||
TraceEvent::TransitionFired { from: Some(SL), to: SL },
|
||||
exit_(SL, A_L_OUT),
|
||||
TraceEvent::Exited { state: SL },
|
||||
TraceEvent::Entered { state: SL },
|
||||
entry(SL, A_L_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SL,
|
||||
event: E_SELFLEAF,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL),
|
||||
to: SL,
|
||||
},
|
||||
exit_(SL, A_L_OUT),
|
||||
TraceEvent::Exited { state: SL },
|
||||
TraceEvent::Entered { state: SL },
|
||||
entry(SL, A_L_IN),
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_self_leaf_does_not_touch_parent() {
|
||||
// The key spec example. Mid must NOT appear in any Exited or Entered
|
||||
// event after the initial descent.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let j = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let j = m.take_journal();
|
||||
|
||||
// Find the index of the EventDelivered for SelfLeaf — everything
|
||||
// after that is the self-transition body.
|
||||
let split = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_SELFLEAF, .. }
|
||||
)).unwrap();
|
||||
// Find the index of the EventDelivered for SelfLeaf — everything
|
||||
// after that is the self-transition body.
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered {
|
||||
event: E_SELFLEAF,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let parent_after = j[split..].iter().filter(|e| matches!(
|
||||
e,
|
||||
TraceEvent::Entered { state: SM } | TraceEvent::Exited { state: SM }
|
||||
)).count();
|
||||
assert_eq!(parent_after, 0,
|
||||
let parent_after = j[split..]
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::Entered { state: SM } | TraceEvent::Exited { state: SM }
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(parent_after, 0,
|
||||
"Mid must not be exited or re-entered during self-transition on Leaf; got tail: {:?}",
|
||||
&j[split..]);
|
||||
}).await;
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
@@ -156,56 +198,86 @@ async fn det_self_composite_default_descends() {
|
||||
// (See det_up.rs for explicit up-transition tests; this test pins the
|
||||
// bubbling behavior when the handler is on Mid but the transition is to
|
||||
// Mid itself from Leaf.)
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfMid).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfMid).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
// Per spec rules: target=Mid is ON the active path (Root→Mid→Leaf),
|
||||
// and target ≠ Leaf. So it's an UP-transition: only Leaf exits.
|
||||
// Mid's entry actions don't fire. No default-descent.
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SM, event: E_SELFMID },
|
||||
TraceEvent::TransitionFired { from: Some(SL), to: SM },
|
||||
exit_(SL, A_L_OUT),
|
||||
TraceEvent::Exited { state: SL },
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
// Per spec rules: target=Mid is ON the active path (Root→Mid→Leaf),
|
||||
// and target ≠ Leaf. So it's an UP-transition: only Leaf exits.
|
||||
// Mid's entry actions don't fire. No default-descent.
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SM,
|
||||
event: E_SELFMID,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL),
|
||||
to: SM,
|
||||
},
|
||||
exit_(SL, A_L_OUT),
|
||||
TraceEvent::Exited { state: SL },
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_self_leaf_repeated_is_deterministic() {
|
||||
// Multiple self-transitions: each independently records the same pattern.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let j = m.take_journal();
|
||||
// Three EventDelivered for SelfLeaf, three TransitionFired, three of each
|
||||
// exit/entry pair.
|
||||
let delivered = j.iter().filter(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_SELFLEAF, .. }
|
||||
)).count();
|
||||
assert_eq!(delivered, 3);
|
||||
let trans = j.iter().filter(|e| matches!(
|
||||
e, TraceEvent::TransitionFired { from: Some(SL), to: SL }
|
||||
)).count();
|
||||
assert_eq!(trans, 3);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let j = m.take_journal();
|
||||
// Three EventDelivered for SelfLeaf, three TransitionFired, three of each
|
||||
// exit/entry pair.
|
||||
let delivered = j
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered {
|
||||
event: E_SELFLEAF,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(delivered, 3);
|
||||
let trans = j
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SL),
|
||||
to: SL
|
||||
}
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(trans, 3);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_self_byte_deterministic_across_runs() {
|
||||
let run = || async {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
m.take_journal()
|
||||
}).await
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
m.take_journal()
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = run().await;
|
||||
for i in 1..10 {
|
||||
@@ -217,18 +289,35 @@ async fn det_self_byte_deterministic_across_runs() {
|
||||
async fn det_self_action_kinds_are_exit_then_entry() {
|
||||
// Spec: "cancel durings, run exits, run entries, start durings".
|
||||
// No durings on this chart, so we expect: Exit kind action, then Entry kind action.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let j = m.take_journal();
|
||||
let split = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_SELFLEAF, .. }
|
||||
)).unwrap();
|
||||
let kinds_after: Vec<ActionKind> = j[split..].iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { kind, .. } => Some(*kind),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(kinds_after, vec![ActionKind::Exit, ActionKind::Entry],
|
||||
"self-transition: exit before entry");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Self_::new(Ctx);
|
||||
let _ = m.dispatch(Ev::SelfLeaf).await;
|
||||
let j = m.take_journal();
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered {
|
||||
event: E_SELFLEAF,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let kinds_after: Vec<ActionKind> = j[split..]
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { kind, .. } => Some(*kind),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds_after,
|
||||
vec![ActionKind::Exit, ActionKind::Entry],
|
||||
"self-transition: exit before entry"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -21,8 +21,8 @@ pub struct Ctx;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Ev {
|
||||
UpToParent, // D → Parent (up-transition)
|
||||
SelfParent, // Parent → Parent (self-transition on composite)
|
||||
UpToParent, // D → Parent (up-transition)
|
||||
SelfParent, // Parent → Parent (self-transition on composite)
|
||||
Halt,
|
||||
}
|
||||
|
||||
@@ -53,11 +53,11 @@ statechart! {
|
||||
}
|
||||
|
||||
impl ScActions for ScActionContext<'_> {
|
||||
async fn p_in(&mut self) {}
|
||||
async fn p_in(&mut self) {}
|
||||
async fn p_out(&mut self) {}
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_out(&mut self) {}
|
||||
async fn l_in(&mut self) {}
|
||||
async fn l_in(&mut self) {}
|
||||
async fn l_out(&mut self) {}
|
||||
}
|
||||
|
||||
@@ -70,63 +70,107 @@ const SL: u16 = 3;
|
||||
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();
|
||||
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..];
|
||||
// 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();
|
||||
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;
|
||||
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::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
|
||||
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 {
|
||||
|
||||
@@ -40,9 +40,9 @@ statechart! {
|
||||
}
|
||||
|
||||
impl EqActions for EqActionContext<'_> {
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_out(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_out(&mut self) {}
|
||||
}
|
||||
|
||||
@@ -50,80 +50,98 @@ impl EqActions for EqActionContext<'_> {
|
||||
async fn det_dispatch_drains_to_quiescence() {
|
||||
// dispatch(ev) processes ev plus any events emitted while handling ev.
|
||||
// After the await returns, the queue is empty.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Eq::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Tick).await;
|
||||
assert!(!m.has_pending_events());
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Eq::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Tick).await;
|
||||
assert!(!m.has_pending_events());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_step_and_dispatch_produce_same_journal() {
|
||||
// Drive one machine via dispatch, another via send + step until queue empty.
|
||||
// The journals must match.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
// Path 1: dispatch.
|
||||
let mut m1 = Eq::new(Ctx);
|
||||
let _ = m1.dispatch(Ev::Tick).await;
|
||||
let _ = m1.dispatch(Ev::Tick).await;
|
||||
let _ = m1.dispatch(Ev::Halt).await;
|
||||
let j_dispatch = m1.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
// Path 1: dispatch.
|
||||
let mut m1 = Eq::new(Ctx);
|
||||
let _ = m1.dispatch(Ev::Tick).await;
|
||||
let _ = m1.dispatch(Ev::Tick).await;
|
||||
let _ = m1.dispatch(Ev::Halt).await;
|
||||
let j_dispatch = m1.take_journal();
|
||||
|
||||
// Path 2: send + step.
|
||||
let mut m2 = Eq::new(Ctx);
|
||||
m2.send(Ev::Tick).unwrap();
|
||||
m2.send(Ev::Tick).unwrap();
|
||||
m2.send(Ev::Halt).unwrap();
|
||||
// Drive to quiescence with explicit steps.
|
||||
for _ in 0..20 {
|
||||
let _ = m2.step(::hsmc::Duration::ZERO).await;
|
||||
if m2.is_terminated() { break; }
|
||||
}
|
||||
let j_step = m2.take_journal();
|
||||
// Path 2: send + step.
|
||||
let mut m2 = Eq::new(Ctx);
|
||||
m2.send(Ev::Tick).unwrap();
|
||||
m2.send(Ev::Tick).unwrap();
|
||||
m2.send(Ev::Halt).unwrap();
|
||||
// Drive to quiescence with explicit steps.
|
||||
for _ in 0..20 {
|
||||
let _ = m2.step(::hsmc::Duration::ZERO).await;
|
||||
if m2.is_terminated() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let j_step = m2.take_journal();
|
||||
|
||||
assert_eq!(j_dispatch, j_step,
|
||||
"step and dispatch must produce identical journals for the same event sequence");
|
||||
}).await;
|
||||
assert_eq!(
|
||||
j_dispatch, j_step,
|
||||
"step and dispatch must produce identical journals for the same event sequence"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_step_is_at_most_one_unit_of_work() {
|
||||
// Each step() processes at most one event or one timer.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Eq::new(Ctx);
|
||||
m.send(Ev::Tick).unwrap();
|
||||
m.send(Ev::Tick).unwrap();
|
||||
m.send(Ev::Halt).unwrap();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Eq::new(Ctx);
|
||||
m.send(Ev::Tick).unwrap();
|
||||
m.send(Ev::Tick).unwrap();
|
||||
m.send(Ev::Halt).unwrap();
|
||||
|
||||
// After the first step (initial enter): chart enters root → A.
|
||||
let _ = m.step(::hsmc::Duration::ZERO).await;
|
||||
// After the second step: process Tick #1 (A → B).
|
||||
let len_after_2 = {
|
||||
// After the first step (initial enter): chart enters root → A.
|
||||
let _ = m.step(::hsmc::Duration::ZERO).await;
|
||||
m.journal().len()
|
||||
};
|
||||
// After the third step: process Tick #2 (B → A).
|
||||
let _ = m.step(::hsmc::Duration::ZERO).await;
|
||||
let len_after_3 = m.journal().len();
|
||||
// After the second step: process Tick #1 (A → B).
|
||||
let len_after_2 = {
|
||||
let _ = m.step(::hsmc::Duration::ZERO).await;
|
||||
m.journal().len()
|
||||
};
|
||||
// After the third step: process Tick #2 (B → A).
|
||||
let _ = m.step(::hsmc::Duration::ZERO).await;
|
||||
let len_after_3 = m.journal().len();
|
||||
|
||||
assert!(len_after_3 > len_after_2, "step processes additional events");
|
||||
}).await;
|
||||
assert!(
|
||||
len_after_3 > len_after_2,
|
||||
"step processes additional events"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_step_repeated_idempotent_when_queue_empty() {
|
||||
// Once initial-enter is done and queue is empty, additional steps
|
||||
// (with elapsed=ZERO) are no-ops.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Eq::new(Ctx);
|
||||
let _ = m.step(::hsmc::Duration::ZERO).await; // initial entry
|
||||
let len_after_first = m.journal().len();
|
||||
for _ in 0..10 {
|
||||
let _ = m.step(::hsmc::Duration::ZERO).await;
|
||||
}
|
||||
// After the first step, no events are pending, so additional
|
||||
// step(ZERO) calls do nothing.
|
||||
assert_eq!(m.journal().len(), len_after_first,
|
||||
"step(ZERO) on empty queue must not change journal");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Eq::new(Ctx);
|
||||
let _ = m.step(::hsmc::Duration::ZERO).await; // initial entry
|
||||
let len_after_first = m.journal().len();
|
||||
for _ in 0..10 {
|
||||
let _ = m.step(::hsmc::Duration::ZERO).await;
|
||||
}
|
||||
// After the first step, no events are pending, so additional
|
||||
// step(ZERO) calls do nothing.
|
||||
assert_eq!(
|
||||
m.journal().len(),
|
||||
len_after_first,
|
||||
"step(ZERO) on empty queue must not change journal"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+117
-80
@@ -47,13 +47,13 @@ statechart! {
|
||||
}
|
||||
|
||||
impl TermActions for TermActionContext<'_> {
|
||||
async fn r_in(&mut self) {}
|
||||
async fn r_out(&mut self) {}
|
||||
async fn m_in(&mut self) {}
|
||||
async fn m_out(&mut self) {}
|
||||
async fn l_in(&mut self) {}
|
||||
async fn l_out(&mut self) {}
|
||||
async fn log_other(&mut self){}
|
||||
async fn r_in(&mut self) {}
|
||||
async fn r_out(&mut self) {}
|
||||
async fn m_in(&mut self) {}
|
||||
async fn m_out(&mut self) {}
|
||||
async fn l_in(&mut self) {}
|
||||
async fn l_out(&mut self) {}
|
||||
async fn log_other(&mut self) {}
|
||||
}
|
||||
|
||||
const SR: u16 = 0;
|
||||
@@ -67,77 +67,110 @@ const E_HALT: u16 = 1;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminate_records_terminate_requested() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
// TerminateRequested fires before the bottom-up exits.
|
||||
let req_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::TerminateRequested { event: E_HALT }
|
||||
));
|
||||
assert!(req_idx.is_some(), "TerminateRequested must appear, got: {:?}", j);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
// TerminateRequested fires before the bottom-up exits.
|
||||
let req_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TerminateRequested { event: E_HALT }));
|
||||
assert!(
|
||||
req_idx.is_some(),
|
||||
"TerminateRequested must appear, got: {:?}",
|
||||
j
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminate_exits_bottom_up() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exits: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(exits, vec![SL, SM, SR], "termination exits inner-to-outer");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exits: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(exits, vec![SL, SM, SR], "termination exits inner-to-outer");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminate_terminated_is_last() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
assert!(matches!(m.journal().last(), Some(TraceEvent::Terminated)));
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
assert!(matches!(m.journal().last(), Some(TraceEvent::Terminated)));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminate_dispatch_after_returns_already_terminated() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let res = m.dispatch(Ev::Other).await;
|
||||
assert!(matches!(res, Err(HsmcError::AlreadyTerminated)),
|
||||
"dispatch after terminate must return AlreadyTerminated, got {:?}", res);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let res = m.dispatch(Ev::Other).await;
|
||||
assert!(
|
||||
matches!(res, Err(HsmcError::AlreadyTerminated)),
|
||||
"dispatch after terminate must return AlreadyTerminated, got {:?}",
|
||||
res
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminate_journal_unchanged_by_post_term_dispatch() {
|
||||
// Calling dispatch after terminate must not journal anything new.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let len_before = m.journal().len();
|
||||
let _ = m.dispatch(Ev::Other).await;
|
||||
let len_after = m.journal().len();
|
||||
assert_eq!(len_before, len_after,
|
||||
"journal length must not change after termination");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let len_before = m.journal().len();
|
||||
let _ = m.dispatch(Ev::Other).await;
|
||||
let len_after = m.journal().len();
|
||||
assert_eq!(
|
||||
len_before, len_after,
|
||||
"journal length must not change after termination"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminate_exit_actions_run_in_order() {
|
||||
// Spec: exit actions inner-to-outer.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exit_actions: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { state, kind: ActionKind::Exit, .. } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(exit_actions, vec![SL, SM, SR]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let exit_actions: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
kind: ActionKind::Exit,
|
||||
..
|
||||
} => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(exit_actions, vec![SL, SM, SR]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
@@ -145,35 +178,39 @@ async fn det_terminate_other_event_processed_before_halt() {
|
||||
// Other is dispatched first, then Halt. Other is delivered, then Halt
|
||||
// triggers termination. EventDelivered for Other appears, but no
|
||||
// EventDelivered for Halt — instead, TerminateRequested.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Other).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let other_delivered = j.iter().any(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_OTHER, .. }
|
||||
));
|
||||
let halt_delivered = j.iter().any(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_HALT, .. }
|
||||
));
|
||||
let halt_requested = j.iter().any(|e| matches!(
|
||||
e, TraceEvent::TerminateRequested { event: E_HALT }
|
||||
));
|
||||
assert!(other_delivered, "Other must be delivered");
|
||||
assert!(!halt_delivered, "Halt does not go through EventDelivered");
|
||||
assert!(halt_requested, "Halt goes through TerminateRequested");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Other).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let other_delivered = j
|
||||
.iter()
|
||||
.any(|e| matches!(e, TraceEvent::EventDelivered { event: E_OTHER, .. }));
|
||||
let halt_delivered = j
|
||||
.iter()
|
||||
.any(|e| matches!(e, TraceEvent::EventDelivered { event: E_HALT, .. }));
|
||||
let halt_requested = j
|
||||
.iter()
|
||||
.any(|e| matches!(e, TraceEvent::TerminateRequested { event: E_HALT }));
|
||||
assert!(other_delivered, "Other must be delivered");
|
||||
assert!(!halt_delivered, "Halt does not go through EventDelivered");
|
||||
assert!(halt_requested, "Halt goes through TerminateRequested");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_terminate_byte_deterministic() {
|
||||
let run = || async {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Other).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
}).await
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Term::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Other).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = run().await;
|
||||
for i in 1..10 {
|
||||
|
||||
+199
-96
@@ -11,8 +11,8 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
use core::time::Duration;
|
||||
use hsmc::{statechart, ActionKind, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -44,10 +44,10 @@ statechart! {
|
||||
}
|
||||
|
||||
impl TimActions for TimActionContext<'_> {
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_out(&mut self) {}
|
||||
async fn d_in(&mut self) {}
|
||||
async fn log_tick(&mut self){}
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_out(&mut self) {}
|
||||
async fn d_in(&mut self) {}
|
||||
async fn log_tick(&mut self) {}
|
||||
}
|
||||
|
||||
const SR: u16 = 0;
|
||||
@@ -58,124 +58,221 @@ const T_COUNTING: u16 = 0;
|
||||
const T_DONE: u16 = 1;
|
||||
|
||||
fn timer_events(j: &[TraceEvent]) -> Vec<&TraceEvent> {
|
||||
j.iter().filter(|e| matches!(
|
||||
e,
|
||||
TraceEvent::TimerArmed { .. }
|
||||
| TraceEvent::TimerCancelled { .. }
|
||||
| TraceEvent::TimerFired { .. }
|
||||
)).collect()
|
||||
j.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TimerArmed { .. }
|
||||
| TraceEvent::TimerCancelled { .. }
|
||||
| TraceEvent::TimerFired { .. }
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_timer_arms_on_entry() {
|
||||
// Spec: timer starts when its state is entered.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let j = m.take_journal();
|
||||
// First TimerArmed must be Counting's 50ms timer.
|
||||
let first_armed = j.iter().find(|e| matches!(e, TraceEvent::TimerArmed { .. }));
|
||||
assert!(matches!(
|
||||
first_armed,
|
||||
Some(TraceEvent::TimerArmed { state: SC, timer: T_COUNTING, ns: 50_000_000 })
|
||||
), "got: {:?}", first_armed);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let j = m.take_journal();
|
||||
// First TimerArmed must be Counting's 50ms timer.
|
||||
let first_armed = j
|
||||
.iter()
|
||||
.find(|e| matches!(e, TraceEvent::TimerArmed { .. }));
|
||||
assert!(
|
||||
matches!(
|
||||
first_armed,
|
||||
Some(TraceEvent::TimerArmed {
|
||||
state: SC,
|
||||
timer: T_COUNTING,
|
||||
ns: 50_000_000
|
||||
})
|
||||
),
|
||||
"got: {:?}",
|
||||
first_armed
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_timer_cancels_on_exit() {
|
||||
// Spec: timer is cancelled when its state is exited.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let j = m.take_journal();
|
||||
// Stop transitions Counting → Done. Exit Counting must cancel its timer.
|
||||
let cancelled = j.iter().find(|e| matches!(
|
||||
e, TraceEvent::TimerCancelled { state: SC, timer: T_COUNTING }
|
||||
));
|
||||
assert!(cancelled.is_some(),
|
||||
"Counting's timer must be cancelled on exit, got: {:?}", j);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let j = m.take_journal();
|
||||
// Stop transitions Counting → Done. Exit Counting must cancel its timer.
|
||||
let cancelled = j.iter().find(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TimerCancelled {
|
||||
state: SC,
|
||||
timer: T_COUNTING
|
||||
}
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
cancelled.is_some(),
|
||||
"Counting's timer must be cancelled on exit, got: {:?}",
|
||||
j
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_timer_cancel_appears_before_exit_actions() {
|
||||
// Spec: "1. cancel durings 2. cancel timers 3. exit actions 4. Exited"
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let j = m.take_journal();
|
||||
let cancel_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::TimerCancelled { state: SC, .. }
|
||||
)).unwrap();
|
||||
let exit_action_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { state: SC, kind: ActionKind::Exit, .. }
|
||||
)).unwrap();
|
||||
assert!(cancel_idx < exit_action_idx,
|
||||
"TimerCancelled at {} must precede ActionInvoked Exit at {}",
|
||||
cancel_idx, exit_action_idx);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let j = m.take_journal();
|
||||
let cancel_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TimerCancelled { state: SC, .. }))
|
||||
.unwrap();
|
||||
let exit_action_idx = j
|
||||
.iter()
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SC,
|
||||
kind: ActionKind::Exit,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
cancel_idx < exit_action_idx,
|
||||
"TimerCancelled at {} must precede ActionInvoked Exit at {}",
|
||||
cancel_idx,
|
||||
exit_action_idx
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_timer_arm_appears_after_entry_actions() {
|
||||
// The TimerArmed must come AFTER the entry action invocation.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let j = m.take_journal();
|
||||
// First Entry-kind action on Counting:
|
||||
let entry_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { state: SC, kind: ActionKind::Entry, .. }
|
||||
)).unwrap();
|
||||
// First TimerArmed for Counting:
|
||||
let armed_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::TimerArmed { state: SC, .. }
|
||||
)).unwrap();
|
||||
assert!(entry_idx < armed_idx,
|
||||
"entry actions ({}) must fire before TimerArmed ({})",
|
||||
entry_idx, armed_idx);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let j = m.take_journal();
|
||||
// First Entry-kind action on Counting:
|
||||
let entry_idx = j
|
||||
.iter()
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SC,
|
||||
kind: ActionKind::Entry,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
// First TimerArmed for Counting:
|
||||
let armed_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TimerArmed { state: SC, .. }))
|
||||
.unwrap();
|
||||
assert!(
|
||||
entry_idx < armed_idx,
|
||||
"entry actions ({}) must fire before TimerArmed ({})",
|
||||
entry_idx,
|
||||
armed_idx
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_timer_arm_each_state_in_path() {
|
||||
// Initial descent into Counting arms Counting's timer.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let armed: Vec<&TraceEvent> = m.journal().iter().filter(|e| matches!(
|
||||
e, TraceEvent::TimerArmed { .. }
|
||||
)).collect();
|
||||
// Only Counting has a timer in initial descent (Done is unreached).
|
||||
assert_eq!(armed.len(), 1, "expected 1 TimerArmed during initial descent, got {:?}", armed);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let armed: Vec<&TraceEvent> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::TimerArmed { .. }))
|
||||
.collect();
|
||||
// Only Counting has a timer in initial descent (Done is unreached).
|
||||
assert_eq!(
|
||||
armed.len(),
|
||||
1,
|
||||
"expected 1 TimerArmed during initial descent, got {:?}",
|
||||
armed
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_timer_two_lifecycles_arm_then_cancel_each() {
|
||||
// GoChild then UpToCounting (no such event — use Stop for full exit).
|
||||
// Just check Stop's full sequence: arm → cancel.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let evs = timer_events(m.journal());
|
||||
assert_eq!(evs.len(), 3, "expected arm → cancel → arm (Done's timer), got {:?}", evs);
|
||||
assert!(matches!(evs[0], TraceEvent::TimerArmed { state: SC, timer: T_COUNTING, ns: 50_000_000 }));
|
||||
assert!(matches!(evs[1], TraceEvent::TimerCancelled { state: SC, timer: T_COUNTING }));
|
||||
assert!(matches!(evs[2], TraceEvent::TimerArmed { state: SD, timer: T_DONE, ns: 20_000_000 }));
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let evs = timer_events(m.journal());
|
||||
assert_eq!(
|
||||
evs.len(),
|
||||
3,
|
||||
"expected arm → cancel → arm (Done's timer), got {:?}",
|
||||
evs
|
||||
);
|
||||
assert!(matches!(
|
||||
evs[0],
|
||||
TraceEvent::TimerArmed {
|
||||
state: SC,
|
||||
timer: T_COUNTING,
|
||||
ns: 50_000_000
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
evs[1],
|
||||
TraceEvent::TimerCancelled {
|
||||
state: SC,
|
||||
timer: T_COUNTING
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
evs[2],
|
||||
TraceEvent::TimerArmed {
|
||||
state: SD,
|
||||
timer: T_DONE,
|
||||
ns: 20_000_000
|
||||
}
|
||||
));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_timer_byte_deterministic() {
|
||||
let run = || async {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
}).await
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
m.take_journal()
|
||||
})
|
||||
.await
|
||||
};
|
||||
let first = run().await;
|
||||
for i in 1..10 {
|
||||
@@ -186,13 +283,19 @@ async fn det_timer_byte_deterministic() {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_timer_state_cancel_uses_correct_state_id() {
|
||||
// The TimerCancelled.state must be SC, not SR or any other.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let cancels: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::TimerCancelled { state, .. } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(cancels, vec![SC]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Tim::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
let cancels: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::TimerCancelled { state, .. } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(cancels, vec![SC]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
#![cfg(all(feature = "tokio", feature = "journal"))]
|
||||
#![allow(unexpected_cfgs)]
|
||||
|
||||
use hsmc::{statechart, TraceEvent};
|
||||
use core::time::Duration;
|
||||
use hsmc::{statechart, TraceEvent};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Ctx;
|
||||
@@ -43,11 +43,11 @@ statechart! {
|
||||
}
|
||||
|
||||
impl PtActions for PtActionContext<'_> {
|
||||
async fn p_in(&mut self) {}
|
||||
async fn p_in(&mut self) {}
|
||||
async fn p_out(&mut self) {}
|
||||
async fn ca_in(&mut self) {}
|
||||
async fn ca_out(&mut self){}
|
||||
async fn d_in(&mut self) {}
|
||||
async fn ca_out(&mut self) {}
|
||||
async fn d_in(&mut self) {}
|
||||
}
|
||||
|
||||
const SP: u16 = 1;
|
||||
@@ -57,50 +57,79 @@ const T_PARENT: u16 = 0;
|
||||
async fn det_parent_timer_armed_before_child_entered() {
|
||||
// Parent's timer arms during Parent's entry; THEN default-descends into ChildA.
|
||||
// So the order is: TimerArmed(Parent) before Entered(ChildA).
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Pt::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let armed_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::TimerArmed { state: SP, .. }
|
||||
)).unwrap();
|
||||
let child_entered_idx = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::Entered { state: 2 } // ChildA = id 2
|
||||
)).unwrap();
|
||||
assert!(armed_idx < child_entered_idx,
|
||||
"TimerArmed (Parent) at {} must precede Entered (ChildA) at {}",
|
||||
armed_idx, child_entered_idx);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Pt::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
let armed_idx = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TimerArmed { state: SP, .. }))
|
||||
.unwrap();
|
||||
let child_entered_idx = j
|
||||
.iter()
|
||||
.position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::Entered { state: 2 } // ChildA = id 2
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
armed_idx < child_entered_idx,
|
||||
"TimerArmed (Parent) at {} must precede Entered (ChildA) at {}",
|
||||
armed_idx,
|
||||
child_entered_idx
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_parent_timer_only_one_arm_in_initial_descent() {
|
||||
// Only Parent has a timer. ChildA has none.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Pt::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let armed: Vec<u16> = m.journal().iter().filter_map(|e| match e {
|
||||
TraceEvent::TimerArmed { state, .. } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(armed, vec![SP]);
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Pt::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let armed: Vec<u16> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::TimerArmed { state, .. } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(armed, vec![SP]);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_parent_timer_cancelled_on_termination() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Pt::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let cancels: Vec<&TraceEvent> = m.journal().iter().filter(|e| matches!(
|
||||
e, TraceEvent::TimerCancelled { .. }
|
||||
)).collect();
|
||||
// Termination unwinds all states. Parent's timer is cancelled when Parent exits.
|
||||
assert_eq!(cancels.len(), 1,
|
||||
"expected exactly 1 TimerCancelled (Parent's), got {:?}", cancels);
|
||||
assert!(matches!(
|
||||
cancels[0],
|
||||
TraceEvent::TimerCancelled { state: SP, timer: T_PARENT }
|
||||
));
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Pt::new(Ctx);
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let cancels: Vec<&TraceEvent> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::TimerCancelled { .. }))
|
||||
.collect();
|
||||
// Termination unwinds all states. Parent's timer is cancelled when Parent exits.
|
||||
assert_eq!(
|
||||
cancels.len(),
|
||||
1,
|
||||
"expected exactly 1 TimerCancelled (Parent's), got {:?}",
|
||||
cancels
|
||||
);
|
||||
assert!(matches!(
|
||||
cancels[0],
|
||||
TraceEvent::TimerCancelled {
|
||||
state: SP,
|
||||
timer: T_PARENT
|
||||
}
|
||||
));
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+212
-127
@@ -66,15 +66,15 @@ statechart! {
|
||||
}
|
||||
|
||||
impl UpActions for UpActionContext<'_> {
|
||||
async fn r_in(&mut self) {}
|
||||
async fn r_in(&mut self) {}
|
||||
async fn r_out(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_in(&mut self) {}
|
||||
async fn a_out(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_in(&mut self) {}
|
||||
async fn b_out(&mut self) {}
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_in(&mut self) {}
|
||||
async fn c_out(&mut self) {}
|
||||
async fn d_in(&mut self) {}
|
||||
async fn d_in(&mut self) {}
|
||||
async fn d_out(&mut self) {}
|
||||
}
|
||||
|
||||
@@ -99,14 +99,24 @@ const E_UPTOB: u16 = 1;
|
||||
const E_UPTOC: u16 = 2;
|
||||
|
||||
fn entry(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Entry }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Entry,
|
||||
}
|
||||
}
|
||||
fn exit_(state: u16, action: u16) -> TraceEvent {
|
||||
TraceEvent::ActionInvoked { state, action, kind: ActionKind::Exit }
|
||||
TraceEvent::ActionInvoked {
|
||||
state,
|
||||
action,
|
||||
kind: ActionKind::Exit,
|
||||
}
|
||||
}
|
||||
fn initial_descent() -> Vec<TraceEvent> {
|
||||
vec![
|
||||
TraceEvent::Started { chart_hash: Up::<8>::CHART_HASH },
|
||||
TraceEvent::Started {
|
||||
chart_hash: Up::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: SR },
|
||||
entry(SR, A_R_IN),
|
||||
TraceEvent::Entered { state: SA },
|
||||
@@ -120,170 +130,245 @@ fn initial_descent() -> Vec<TraceEvent> {
|
||||
]
|
||||
}
|
||||
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
|
||||
let mismatch = actual.iter().zip(expected.iter())
|
||||
.position(|(a, e)| a != e);
|
||||
let mismatch = actual.iter().zip(expected.iter()).position(|(a, e)| a != e);
|
||||
if let Some(i) = mismatch {
|
||||
panic!("mismatch at index {}\n actual: {:?}\n expected: {:?}",
|
||||
i, actual.get(i), expected.get(i));
|
||||
panic!(
|
||||
"mismatch at index {}\n actual: {:?}\n expected: {:?}",
|
||||
i,
|
||||
actual.get(i),
|
||||
expected.get(i)
|
||||
);
|
||||
}
|
||||
assert_eq!(actual.len(), expected.len(),
|
||||
"length mismatch: actual {} vs expected {}", actual.len(), expected.len());
|
||||
assert_eq!(
|
||||
actual.len(),
|
||||
expected.len(),
|
||||
"length mismatch: actual {} vs expected {}",
|
||||
actual.len(),
|
||||
expected.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_to_immediate_grandparent() {
|
||||
// D → C: exit D only. C is target, not exited, not re-entered.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToC).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToC).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SD, event: E_UPTOC },
|
||||
TraceEvent::TransitionFired { from: Some(SD), to: SC },
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SD,
|
||||
event: E_UPTOC,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SD),
|
||||
to: SC,
|
||||
},
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_two_levels() {
|
||||
// D → B: exit D, exit C. B is target, not exited.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToB).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToB).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SD, event: E_UPTOB },
|
||||
TraceEvent::TransitionFired { from: Some(SD), to: SB },
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
exit_(SC, A_C_OUT),
|
||||
TraceEvent::Exited { state: SC },
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SD,
|
||||
event: E_UPTOB,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SD),
|
||||
to: SB,
|
||||
},
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
exit_(SC, A_C_OUT),
|
||||
TraceEvent::Exited { state: SC },
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_three_levels_to_a() {
|
||||
// D → A: exit D, C, B. A is target, not exited.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToA).await;
|
||||
let actual = m.take_journal();
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToA).await;
|
||||
let actual = m.take_journal();
|
||||
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered { handler_state: SD, event: E_UPTOA },
|
||||
TraceEvent::TransitionFired { from: Some(SD), to: SA },
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
exit_(SC, A_C_OUT),
|
||||
TraceEvent::Exited { state: SC },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
}).await;
|
||||
let mut expected = initial_descent();
|
||||
expected.extend(vec![
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: SD,
|
||||
event: E_UPTOA,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(SD),
|
||||
to: SA,
|
||||
},
|
||||
exit_(SD, A_D_OUT),
|
||||
TraceEvent::Exited { state: SD },
|
||||
exit_(SC, A_C_OUT),
|
||||
TraceEvent::Exited { state: SC },
|
||||
exit_(SB, A_B_OUT),
|
||||
TraceEvent::Exited { state: SB },
|
||||
]);
|
||||
assert_journal(&actual, &expected);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_target_not_re_entered() {
|
||||
// After D → A: A must NOT appear in any Entered event after the initial descent.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToA).await;
|
||||
let j = m.take_journal();
|
||||
let split = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_UPTOA, .. }
|
||||
)).unwrap();
|
||||
let a_re_entered = j[split..].iter().filter(|e| matches!(
|
||||
e, TraceEvent::Entered { state: SA }
|
||||
)).count();
|
||||
assert_eq!(a_re_entered, 0, "target A must not re-enter on up-transition");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToA).await;
|
||||
let j = m.take_journal();
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::EventDelivered { event: E_UPTOA, .. }))
|
||||
.unwrap();
|
||||
let a_re_entered = j[split..]
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::Entered { state: SA }))
|
||||
.count();
|
||||
assert_eq!(
|
||||
a_re_entered, 0,
|
||||
"target A must not re-enter on up-transition"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_target_action_not_fired() {
|
||||
// After D → A: a_in must NOT fire after the initial descent.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToA).await;
|
||||
let j = m.take_journal();
|
||||
let split = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_UPTOA, .. }
|
||||
)).unwrap();
|
||||
let a_in_after = j[split..].iter().filter(|e| matches!(
|
||||
e, TraceEvent::ActionInvoked { state: SA, action: A_A_IN, .. }
|
||||
)).count();
|
||||
assert_eq!(a_in_after, 0, "target's entry action must not fire on up-transition");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToA).await;
|
||||
let j = m.take_journal();
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::EventDelivered { event: E_UPTOA, .. }))
|
||||
.unwrap();
|
||||
let a_in_after = j[split..]
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::ActionInvoked {
|
||||
state: SA,
|
||||
action: A_A_IN,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
a_in_after, 0,
|
||||
"target's entry action must not fire on up-transition"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_no_default_descent() {
|
||||
// After D → A: no default-descent. The journal stops with Exited(B).
|
||||
// A is the new innermost active state, with no active child.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToA).await;
|
||||
let j = m.take_journal();
|
||||
let split = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::EventDelivered { event: E_UPTOA, .. }
|
||||
)).unwrap();
|
||||
// Nothing after this slice should be Entered (no descent).
|
||||
let entered_after = j[split..].iter().filter(|e| matches!(
|
||||
e, TraceEvent::Entered { .. }
|
||||
)).count();
|
||||
assert_eq!(entered_after, 0, "up-transition: no default-descent");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToA).await;
|
||||
let j = m.take_journal();
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::EventDelivered { event: E_UPTOA, .. }))
|
||||
.unwrap();
|
||||
// Nothing after this slice should be Entered (no descent).
|
||||
let entered_after = j[split..]
|
||||
.iter()
|
||||
.filter(|e| matches!(e, TraceEvent::Entered { .. }))
|
||||
.count();
|
||||
assert_eq!(entered_after, 0, "up-transition: no default-descent");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_current_state_is_target_after() {
|
||||
// After D → B, the innermost active state is B (composite, no active child).
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToB).await;
|
||||
// current_state() should return B's public variant.
|
||||
// Indirect check: the most recent Entered (B's, from initial descent) is the
|
||||
// last Entered before the up-transition; nothing after.
|
||||
let j = m.take_journal();
|
||||
let last_entered = j.iter().enumerate().rev()
|
||||
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
|
||||
.map(|(i, _)| i);
|
||||
let split = j.iter().position(|e| matches!(
|
||||
e, TraceEvent::TransitionFired { .. }
|
||||
)).unwrap();
|
||||
// The last Entered event must be BEFORE the transition.
|
||||
assert!(last_entered.unwrap() < split,
|
||||
"up-transition: no entries after the transition fires");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToB).await;
|
||||
// current_state() should return B's public variant.
|
||||
// Indirect check: the most recent Entered (B's, from initial descent) is the
|
||||
// last Entered before the up-transition; nothing after.
|
||||
let j = m.take_journal();
|
||||
let last_entered = j
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
|
||||
.map(|(i, _)| i);
|
||||
let split = j
|
||||
.iter()
|
||||
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
|
||||
.unwrap();
|
||||
// The last Entered event must be BEFORE the transition.
|
||||
assert!(
|
||||
last_entered.unwrap() < split,
|
||||
"up-transition: no entries after the transition fires"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn det_up_chained_then_lateral() {
|
||||
// D → C, then C is a leaf with no children, so this becomes the new
|
||||
// innermost. From C we don't have any other handlers, so test ends here.
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToC).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
// After UpToC, we should have Exited(D) but C remains. Halt then exits
|
||||
// C → B → A → Root.
|
||||
let exits: Vec<u16> = j.iter().filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
}).collect();
|
||||
assert_eq!(exits, vec![SD, SC, SB, SA, SR],
|
||||
"up to C, then terminate exits remaining path");
|
||||
}).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Up::new(Ctx);
|
||||
let _ = m.dispatch(Ev::UpToC).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
let j = m.take_journal();
|
||||
// After UpToC, we should have Exited(D) but C remains. Halt then exits
|
||||
// C → B → A → Root.
|
||||
let exits: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Exited { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
exits,
|
||||
vec![SD, SC, SB, SA, SR],
|
||||
"up to C, then terminate exits remaining path"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+29
-22
@@ -53,31 +53,38 @@ statechart! {
|
||||
}
|
||||
|
||||
impl DumpActions for DumpActionContext<'_> {
|
||||
async fn root_in(&mut self) { self.n += 1; }
|
||||
async fn root_out(&mut self) {}
|
||||
async fn idle_in(&mut self) {}
|
||||
async fn idle_out(&mut self) {}
|
||||
async fn active_in(&mut self) {}
|
||||
async fn active_out(&mut self) {}
|
||||
async fn sub_in(&mut self) {}
|
||||
async fn sub_out(&mut self) {}
|
||||
async fn beep_at_idle(&mut self) {}
|
||||
async fn beep_at_sub(&mut self) {}
|
||||
async fn root_in(&mut self) {
|
||||
self.n += 1;
|
||||
}
|
||||
async fn root_out(&mut self) {}
|
||||
async fn idle_in(&mut self) {}
|
||||
async fn idle_out(&mut self) {}
|
||||
async fn active_in(&mut self) {}
|
||||
async fn active_out(&mut self) {}
|
||||
async fn sub_in(&mut self) {}
|
||||
async fn sub_out(&mut self) {}
|
||||
async fn beep_at_idle(&mut self) {}
|
||||
async fn beep_at_sub(&mut self) {}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn dump_journal() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Dump::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Beep).await;
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let _ = m.dispatch(Ev::Beep).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Dump::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Beep).await;
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let _ = m.dispatch(Ev::Beep).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
|
||||
println!("\n=== JOURNAL DUMP (CHART_HASH={:#x}) ===", Dump::<8>::CHART_HASH);
|
||||
for (i, e) in m.journal().iter().enumerate() {
|
||||
println!(" {:>3}: {:?}", i, e);
|
||||
}
|
||||
println!("=== END ({} events) ===\n", m.journal().len());
|
||||
}).await;
|
||||
println!(
|
||||
"\n=== JOURNAL DUMP (CHART_HASH={:#x}) ===",
|
||||
Dump::<8>::CHART_HASH
|
||||
);
|
||||
for (i, e) in m.journal().iter().enumerate() {
|
||||
println!(" {:>3}: {:?}", i, e);
|
||||
}
|
||||
println!("=== END ({} events) ===\n", m.journal().len());
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -52,14 +52,14 @@ statechart! {
|
||||
}
|
||||
|
||||
impl ReplayActions for ReplayActionContext<'_> {
|
||||
async fn root_in(&mut self) {}
|
||||
async fn root_out(&mut self) {}
|
||||
async fn idle_in(&mut self) {}
|
||||
async fn idle_out(&mut self) {}
|
||||
async fn active_in(&mut self) {}
|
||||
async fn root_in(&mut self) {}
|
||||
async fn root_out(&mut self) {}
|
||||
async fn idle_in(&mut self) {}
|
||||
async fn idle_out(&mut self) {}
|
||||
async fn active_in(&mut self) {}
|
||||
async fn active_out(&mut self) {}
|
||||
async fn sub_in(&mut self) {}
|
||||
async fn sub_out(&mut self) {}
|
||||
async fn sub_in(&mut self) {}
|
||||
async fn sub_out(&mut self) {}
|
||||
}
|
||||
|
||||
// State indices.
|
||||
@@ -84,46 +84,92 @@ const E_HALT: u16 = 1;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn replay_full_lifecycle_matches_expected() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = Replay::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = Replay::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
let _ = m.dispatch(Ev::Halt).await;
|
||||
|
||||
let actual = m.take_journal();
|
||||
let actual = m.take_journal();
|
||||
|
||||
let expected: Vec<TraceEvent> = vec![
|
||||
// ── First step: enter the chart ──
|
||||
TraceEvent::Started { chart_hash: Replay::<8>::CHART_HASH },
|
||||
TraceEvent::Entered { state: ST_ROOT },
|
||||
TraceEvent::ActionInvoked { state: ST_ROOT, action: A_ROOT_IN, kind: ActionKind::Entry },
|
||||
TraceEvent::Entered { state: ST_IDLE },
|
||||
TraceEvent::ActionInvoked { state: ST_IDLE, action: A_IDLE_IN, kind: ActionKind::Entry },
|
||||
let expected: Vec<TraceEvent> = vec![
|
||||
// ── First step: enter the chart ──
|
||||
TraceEvent::Started {
|
||||
chart_hash: Replay::<8>::CHART_HASH,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_ROOT },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_ROOT,
|
||||
action: A_ROOT_IN,
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_IDLE },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_IDLE,
|
||||
action: A_IDLE_IN,
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
// ── Go event delivered to Idle, transition to Active → Sub ──
|
||||
TraceEvent::EventDelivered {
|
||||
handler_state: ST_IDLE,
|
||||
event: E_GO,
|
||||
},
|
||||
TraceEvent::TransitionFired {
|
||||
from: Some(ST_IDLE),
|
||||
to: ST_ACTIVE,
|
||||
},
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_IDLE,
|
||||
action: A_IDLE_OUT,
|
||||
kind: ActionKind::Exit,
|
||||
},
|
||||
TraceEvent::Exited { state: ST_IDLE },
|
||||
TraceEvent::Entered { state: ST_ACTIVE },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_ACTIVE,
|
||||
action: A_ACTIVE_IN,
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
TraceEvent::Entered { state: ST_SUB },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_SUB,
|
||||
action: A_SUB_IN,
|
||||
kind: ActionKind::Entry,
|
||||
},
|
||||
// ── Halt event triggers termination: exit Sub → Active → root, then Terminated ──
|
||||
TraceEvent::TerminateRequested { event: E_HALT },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_SUB,
|
||||
action: A_SUB_OUT,
|
||||
kind: ActionKind::Exit,
|
||||
},
|
||||
TraceEvent::Exited { state: ST_SUB },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_ACTIVE,
|
||||
action: A_ACTIVE_OUT,
|
||||
kind: ActionKind::Exit,
|
||||
},
|
||||
TraceEvent::Exited { state: ST_ACTIVE },
|
||||
TraceEvent::ActionInvoked {
|
||||
state: ST_ROOT,
|
||||
action: A_ROOT_OUT,
|
||||
kind: ActionKind::Exit,
|
||||
},
|
||||
TraceEvent::Exited { state: ST_ROOT },
|
||||
TraceEvent::Terminated,
|
||||
];
|
||||
|
||||
// ── Go event delivered to Idle, transition to Active → Sub ──
|
||||
TraceEvent::EventDelivered { handler_state: ST_IDLE, event: E_GO },
|
||||
TraceEvent::TransitionFired { from: Some(ST_IDLE), to: ST_ACTIVE },
|
||||
TraceEvent::ActionInvoked { state: ST_IDLE, action: A_IDLE_OUT, kind: ActionKind::Exit },
|
||||
TraceEvent::Exited { state: ST_IDLE },
|
||||
TraceEvent::Entered { state: ST_ACTIVE },
|
||||
TraceEvent::ActionInvoked { state: ST_ACTIVE, action: A_ACTIVE_IN, kind: ActionKind::Entry },
|
||||
TraceEvent::Entered { state: ST_SUB },
|
||||
TraceEvent::ActionInvoked { state: ST_SUB, action: A_SUB_IN, kind: ActionKind::Entry },
|
||||
|
||||
// ── Halt event triggers termination: exit Sub → Active → root, then Terminated ──
|
||||
TraceEvent::TerminateRequested { event: E_HALT },
|
||||
TraceEvent::ActionInvoked { state: ST_SUB, action: A_SUB_OUT, kind: ActionKind::Exit },
|
||||
TraceEvent::Exited { state: ST_SUB },
|
||||
TraceEvent::ActionInvoked { state: ST_ACTIVE, action: A_ACTIVE_OUT, kind: ActionKind::Exit },
|
||||
TraceEvent::Exited { state: ST_ACTIVE },
|
||||
TraceEvent::ActionInvoked { state: ST_ROOT, action: A_ROOT_OUT, kind: ActionKind::Exit },
|
||||
TraceEvent::Exited { state: ST_ROOT },
|
||||
TraceEvent::Terminated,
|
||||
];
|
||||
|
||||
let mismatch_at = actual.iter().zip(expected.iter())
|
||||
.position(|(a, e)| a != e)
|
||||
.map(|i| i.to_string())
|
||||
.unwrap_or_else(|| "<length-mismatch>".to_string());
|
||||
assert_eq!(actual, expected, "journal divergence at index {}", mismatch_at);
|
||||
}).await;
|
||||
let mismatch_at = actual
|
||||
.iter()
|
||||
.zip(expected.iter())
|
||||
.position(|(a, e)| a != e)
|
||||
.map(|i| i.to_string())
|
||||
.unwrap_or_else(|| "<length-mismatch>".to_string());
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"journal divergence at index {}",
|
||||
mismatch_at
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -42,22 +42,33 @@ impl EmitProbeActions for EmitProbeActionContext<'_> {
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn emit_queued_appears_in_journal() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = EmitProbe::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = EmitProbe::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Trigger).await;
|
||||
|
||||
let saw_emit = m.journal().iter().any(|e| matches!(
|
||||
e,
|
||||
TraceEvent::EmitQueued { .. }
|
||||
));
|
||||
assert!(saw_emit, "journal must contain EmitQueued, got {:?}", m.journal());
|
||||
let saw_emit = m
|
||||
.journal()
|
||||
.iter()
|
||||
.any(|e| matches!(e, TraceEvent::EmitQueued { .. }));
|
||||
assert!(
|
||||
saw_emit,
|
||||
"journal must contain EmitQueued, got {:?}",
|
||||
m.journal()
|
||||
);
|
||||
|
||||
// The follow-up event MUST be delivered (proving the queue actually
|
||||
// drained the emit'd event).
|
||||
let delivered_followup = m.journal().iter().any(|e| matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered { event: 1, .. } // 1 = Followup (Trigger=0, Followup=1)
|
||||
));
|
||||
assert!(delivered_followup, "expected Followup to be delivered after emit");
|
||||
}).await;
|
||||
// The follow-up event MUST be delivered (proving the queue actually
|
||||
// drained the emit'd event).
|
||||
let delivered_followup = m.journal().iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered { event: 1, .. } // 1 = Followup (Trigger=0, Followup=1)
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
delivered_followup,
|
||||
"expected Followup to be delivered after emit"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -37,34 +37,57 @@ statechart! {
|
||||
}
|
||||
|
||||
impl TimerProbeActions for TimerProbeActionContext<'_> {
|
||||
async fn noop(&mut self) {}
|
||||
async fn noop(&mut self) {}
|
||||
async fn noop2(&mut self) {}
|
||||
async fn noop3(&mut self) {}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn timer_arm_then_cancel_journals_both() {
|
||||
tokio::task::LocalSet::new().run_until(async {
|
||||
let mut m = TimerProbe::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = TimerProbe::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Stop).await;
|
||||
|
||||
let timer_events: Vec<&TraceEvent> = m.journal().iter()
|
||||
.filter(|e| matches!(
|
||||
e,
|
||||
TraceEvent::TimerArmed { .. }
|
||||
| TraceEvent::TimerCancelled { .. }
|
||||
| TraceEvent::TimerFired { .. }
|
||||
))
|
||||
.collect();
|
||||
let timer_events: Vec<&TraceEvent> = m
|
||||
.journal()
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
matches!(
|
||||
e,
|
||||
TraceEvent::TimerArmed { .. }
|
||||
| TraceEvent::TimerCancelled { .. }
|
||||
| TraceEvent::TimerFired { .. }
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(timer_events.len(), 2, "expected 2 timer events, got {:?}", timer_events);
|
||||
assert!(matches!(
|
||||
timer_events[0],
|
||||
TraceEvent::TimerArmed { state: 1, timer: 0, ns: 100_000_000 }
|
||||
), "got {:?}", timer_events[0]);
|
||||
assert!(matches!(
|
||||
timer_events[1],
|
||||
TraceEvent::TimerCancelled { state: 1, timer: 0 }
|
||||
), "got {:?}", timer_events[1]);
|
||||
}).await;
|
||||
assert_eq!(
|
||||
timer_events.len(),
|
||||
2,
|
||||
"expected 2 timer events, got {:?}",
|
||||
timer_events
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
timer_events[0],
|
||||
TraceEvent::TimerArmed {
|
||||
state: 1,
|
||||
timer: 0,
|
||||
ns: 100_000_000
|
||||
}
|
||||
),
|
||||
"got {:?}",
|
||||
timer_events[0]
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
timer_events[1],
|
||||
TraceEvent::TimerCancelled { state: 1, timer: 0 }
|
||||
),
|
||||
"got {:?}",
|
||||
timer_events[1]
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
+79
-22
@@ -58,14 +58,30 @@ statechart! {
|
||||
}
|
||||
|
||||
impl JournalProbeActions for JournalProbeActionContext<'_> {
|
||||
async fn root_in(&mut self) { self.log.push("root_in"); }
|
||||
async fn root_out(&mut self) { self.log.push("root_out"); }
|
||||
async fn idle_in(&mut self) { self.log.push("idle_in"); }
|
||||
async fn idle_out(&mut self) { self.log.push("idle_out"); }
|
||||
async fn active_in(&mut self) { self.log.push("active_in"); }
|
||||
async fn active_out(&mut self) { self.log.push("active_out"); }
|
||||
async fn sub_in(&mut self) { self.log.push("sub_in"); }
|
||||
async fn sub_out(&mut self) { self.log.push("sub_out"); }
|
||||
async fn root_in(&mut self) {
|
||||
self.log.push("root_in");
|
||||
}
|
||||
async fn root_out(&mut self) {
|
||||
self.log.push("root_out");
|
||||
}
|
||||
async fn idle_in(&mut self) {
|
||||
self.log.push("idle_in");
|
||||
}
|
||||
async fn idle_out(&mut self) {
|
||||
self.log.push("idle_out");
|
||||
}
|
||||
async fn active_in(&mut self) {
|
||||
self.log.push("active_in");
|
||||
}
|
||||
async fn active_out(&mut self) {
|
||||
self.log.push("active_out");
|
||||
}
|
||||
async fn sub_in(&mut self) {
|
||||
self.log.push("sub_in");
|
||||
}
|
||||
async fn sub_out(&mut self) {
|
||||
self.log.push("sub_out");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
@@ -92,17 +108,24 @@ async fn journal_records_initial_descent() {
|
||||
// Entered(Idle)
|
||||
// ActionInvoked(Idle, idle_in, Entry)
|
||||
// ... (then the Go event delivery / transition / sub entry)
|
||||
let states_entered: Vec<u16> = j.iter()
|
||||
let states_entered: Vec<u16> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::Entered { state } => Some(*state),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// At minimum we entered root (id 0), then Idle, then Active, then Sub.
|
||||
assert_eq!(states_entered.len(), 4, "expected 4 Entered events, got {:?}", j);
|
||||
assert_eq!(
|
||||
states_entered.len(),
|
||||
4,
|
||||
"expected 4 Entered events, got {:?}",
|
||||
j
|
||||
);
|
||||
|
||||
// Action kinds appear in order Entry → Handler (none here) → Exit.
|
||||
let kinds: Vec<ActionKind> = j.iter()
|
||||
let kinds: Vec<ActionKind> = j
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
TraceEvent::ActionInvoked { kind, .. } => Some(*kind),
|
||||
_ => None,
|
||||
@@ -115,9 +138,17 @@ async fn journal_records_initial_descent() {
|
||||
// Sub enters (sub_in).
|
||||
// Total: Entry(root), Entry(idle), Exit(idle), Entry(active), Entry(sub).
|
||||
let entry_count = kinds.iter().filter(|k| **k == ActionKind::Entry).count();
|
||||
let exit_count = kinds.iter().filter(|k| **k == ActionKind::Exit).count();
|
||||
assert_eq!(entry_count, 4, "expected 4 entry actions, got kinds {:?}", kinds);
|
||||
assert_eq!(exit_count, 1, "expected 1 exit action, got kinds {:?}", kinds);
|
||||
let exit_count = kinds.iter().filter(|k| **k == ActionKind::Exit).count();
|
||||
assert_eq!(
|
||||
entry_count, 4,
|
||||
"expected 4 entry actions, got kinds {:?}",
|
||||
kinds
|
||||
);
|
||||
assert_eq!(
|
||||
exit_count, 1,
|
||||
"expected 1 exit action, got kinds {:?}",
|
||||
kinds
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -129,16 +160,16 @@ async fn journal_includes_transition_event() {
|
||||
let mut m = JournalProbe::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
|
||||
let saw_transition = m.journal().iter().any(|e| matches!(
|
||||
e,
|
||||
TraceEvent::TransitionFired { .. }
|
||||
));
|
||||
let saw_transition = m
|
||||
.journal()
|
||||
.iter()
|
||||
.any(|e| matches!(e, TraceEvent::TransitionFired { .. }));
|
||||
assert!(saw_transition, "journal must contain TransitionFired");
|
||||
|
||||
let saw_event_delivered = m.journal().iter().any(|e| matches!(
|
||||
e,
|
||||
TraceEvent::EventDelivered { .. }
|
||||
));
|
||||
let saw_event_delivered = m
|
||||
.journal()
|
||||
.iter()
|
||||
.any(|e| matches!(e, TraceEvent::EventDelivered { .. }));
|
||||
assert!(saw_event_delivered, "journal must contain EventDelivered");
|
||||
})
|
||||
.await;
|
||||
@@ -195,6 +226,32 @@ async fn journal_is_byte_deterministic_across_runs() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn clear_journal_empties_the_journal() {
|
||||
// Pins down `JournalSink::clear`: after dispatching events that
|
||||
// produce journal entries, calling `clear_journal()` must empty
|
||||
// the journal. Subsequent `journal()` returns an empty slice;
|
||||
// `take_journal()` returns an empty Vec.
|
||||
tokio::task::LocalSet::new()
|
||||
.run_until(async {
|
||||
let mut m = JournalProbe::new(Ctx::default());
|
||||
let _ = m.dispatch(Ev::Go).await;
|
||||
assert!(
|
||||
!m.journal().is_empty(),
|
||||
"journal should have entries before clear"
|
||||
);
|
||||
|
||||
m.clear_journal();
|
||||
assert!(
|
||||
m.journal().is_empty(),
|
||||
"journal must be empty after clear_journal()"
|
||||
);
|
||||
let taken = m.take_journal();
|
||||
assert!(taken.is_empty(), "take_journal() after clear must be empty");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn chart_hash_is_stable() {
|
||||
// Two instances of the same chart must agree on CHART_HASH.
|
||||
|
||||
@@ -135,11 +135,14 @@ coverage:
|
||||
mutants:
|
||||
@echo "Running mutation testing..."
|
||||
@echo "⚠️ This will use significant CPU and memory resources!"
|
||||
# `tokio,journal` so journal_* and det_* tests (gated on both
|
||||
# features) actually run — they're what catches mutants in
|
||||
# JournalSink and the codegen-emitted journal hooks.
|
||||
cargo mutants \
|
||||
--no-shuffle \
|
||||
--test-tool nextest \
|
||||
--jobs 3 \
|
||||
--features tokio \
|
||||
--features tokio,journal \
|
||||
-vV
|
||||
|
||||
# Security audit + dependency checks
|
||||
@@ -159,7 +162,15 @@ miri:
|
||||
rustup component add miri --toolchain nightly
|
||||
fi
|
||||
echo "Running Miri tests for UB detection (no-default-features)..."
|
||||
cargo +nightly miri test --workspace --no-default-features
|
||||
# `--exclude hsmc-macros`: the macro crate's only integration test
|
||||
# is `tests/ui.rs` which uses `trybuild` (compiles fixture files,
|
||||
# globs the filesystem). That hits Miri's isolation sandbox and
|
||||
# tests proc-macro behavior, not UB. Skip it.
|
||||
# `--exclude hsmc-verification`: this crate is built with the
|
||||
# nightly pinned in verification/mise.toml (nightly-2025-11-13);
|
||||
# mixing toolchains under cargo +nightly miri test breaks builds.
|
||||
cargo +nightly miri test --workspace --no-default-features \
|
||||
--exclude hsmc-macros --exclude hsmc-verification
|
||||
|
||||
# Run Creusot deductive verification (self-installs opam, Why3, SMT solvers, cargo-creusot on first run).
|
||||
[script]
|
||||
@@ -238,29 +249,34 @@ verify:
|
||||
fi
|
||||
|
||||
# ── 6. Run the actual verification ───────────────────────────────
|
||||
# We're already cd'd into verification/. mise env set rust to the
|
||||
# pinned nightly.
|
||||
# `cargo creusot prove` invokes why3find which resolves `verif/`
|
||||
# relative to the workspace root (where why3find.json lives). cd
|
||||
# back so the path resolution is correct.
|
||||
#
|
||||
# Two phases:
|
||||
# `cargo creusot` — Rust → Coma IR, dumped into verif/
|
||||
# `cargo creusot prove` — Why3 + SMT solvers discharge VCs
|
||||
#
|
||||
# `prove` exits non-zero when any VC is unproven. We don't `set -e`
|
||||
# past it — unproven VCs are the engineering work, not a hard
|
||||
# error. The exit status is reported at the end.
|
||||
cd ..
|
||||
if [ ! -f why3find.json ]; then
|
||||
printf ' → cargo creusot init (one-time, populates why3find.json)...\n'
|
||||
cargo creusot init || true # exits non-zero in workspaces but writes the file
|
||||
fi
|
||||
printf '\n🚀 cargo creusot (generate Coma IR)...\n'
|
||||
cargo creusot
|
||||
printf '\n🚀 cargo creusot prove (discharge VCs via Why3)...\n'
|
||||
printf '\n🚀 cargo creusot prove --no-cache (force SMT solver invocations)...\n'
|
||||
# `--no-cache` skips why3find's per-Coma-file proof cache and
|
||||
# actually invokes alt-ergo + z3 + cvc4 for every VC. The wall-
|
||||
# clock vs CPU-time gap (`time` output) is the parallel SMT work.
|
||||
set +e
|
||||
cargo creusot prove
|
||||
time cargo creusot prove --no-cache
|
||||
PROVE_RC=$?
|
||||
set -e
|
||||
if [ "$PROVE_RC" -eq 0 ]; then
|
||||
printf '\n✅ All VCs discharged. See INVARIANTS.md for the rule mapping.\n'
|
||||
printf '\n✅ All VCs discharged. See verification/INVARIANTS.md for the rule mapping.\n'
|
||||
else
|
||||
printf '\n⚠ Some VCs unproven (cargo creusot prove exit %d).\n' "$PROVE_RC"
|
||||
printf ' Iterate on contracts in src/event_queue.rs / src/timer_table.rs.\n'
|
||||
printf ' Run `cargo creusot prove` directly from verification/ for tighter loop.\n'
|
||||
printf ' Iterate on contracts in verification/src/event_queue.rs / src/timer_table.rs.\n'
|
||||
printf ' For a tighter loop: cargo creusot prove (from workspace root).\n'
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -29,7 +29,7 @@ hsmc = { path = "../hsmc", default-features = false }
|
||||
# Pinned to creusot v0.9.0 — last tagged release with `creusot-std`.
|
||||
# `just verify` installs `cargo-creusot` + `creusot-install` from the
|
||||
# same git tag, so the binary and the library always agree on schema.
|
||||
creusot-std = { git = "https://github.com/creusot-rs/creusot", tag = "v0.9.0" }
|
||||
creusot-std = "0.9.0"
|
||||
|
||||
[dev-dependencies]
|
||||
# Correspondence tests run our verified mirror against the real runtime
|
||||
|
||||
+75
-101
@@ -1,138 +1,112 @@
|
||||
//! Verified mirror of `hsmc::__private::EventQueue`.
|
||||
//!
|
||||
//! Bounded FIFO queue. The runtime uses `heapless::Deque<E, N>` under the
|
||||
//! hood (a fixed-capacity ring buffer); this mirror uses an array with
|
||||
//! head index + length, which Creusot can reason about directly via the
|
||||
//! `Seq<E>` view returned by [`View::view`].
|
||||
//! FIFO queue. The runtime uses a fixed-capacity ring buffer
|
||||
//! (`heapless::Deque<E, N>`); this mirror uses a `Vec<E>` for which
|
||||
//! creusot-std already ships full specs — push/pop/remove/clear all
|
||||
//! have proven contracts upstream. Our wrapper just exposes the FIFO
|
||||
//! discipline on top.
|
||||
//!
|
||||
//! Spec rules pinned (see `docs/002. hsmc-semantics-formal.md`):
|
||||
//! Spec rules pinned (see `docs/002. hsmc-semantics-formal.md`,
|
||||
//! section "emit() — sending events from inside an action"):
|
||||
//!
|
||||
//! - `push()` returns `Err(QueueFull)` exactly when at capacity.
|
||||
//! - `push()` on a non-full queue grows the view by appending at the back.
|
||||
//! - `pop()` returns `None` exactly when empty; otherwise returns the
|
||||
//! front element and truncates the view from the front.
|
||||
//! - `is_empty()` ⇔ `view().len() == 0`.
|
||||
//! - `clear()` makes the view empty.
|
||||
//! - **S6.FIFO**: push appends to the back; pop returns view[0].
|
||||
//! - **S6.EMPTY**: pop returns `None` iff empty.
|
||||
//! - **S6.CLEAR**: clear empties the view.
|
||||
//! - **S6.LEN**: len matches view length.
|
||||
//!
|
||||
//! Correspondence with the real runtime type is enforced by
|
||||
//! `tests/correspondence.rs`.
|
||||
//! Boundedness (the runtime's `Err(QueueFull)` path) is intentionally
|
||||
//! NOT modeled here — it's a property of the ring buffer at the
|
||||
//! runtime level and is verified by the unit tests in
|
||||
//! `hsmc/src/lib.rs::private_internals` and the correspondence tests
|
||||
//! in `tests/correspondence_event_queue.rs`. Creusot proves the FIFO
|
||||
//! discipline; the unit tests pin the cap behavior.
|
||||
|
||||
// Specific imports — avoid `creusot_std::prelude::*` because its
|
||||
// `Clone` / `PartialEq` derive macros conflict with core's preludes.
|
||||
//
|
||||
// `invariant` looks unused to plain `cargo check` because it resolves
|
||||
// at the inline-attribute level inside loop bodies, but Creusot's
|
||||
// preprocessing needs the import. Allow the false warning.
|
||||
#[allow(unused_imports)]
|
||||
use creusot_std::macros::invariant;
|
||||
use creusot_std::logic::Seq;
|
||||
use creusot_std::model::{DeepModel, View};
|
||||
use creusot_std::macros::{ensures, logic, requires, trusted};
|
||||
use creusot_std::macros::ensures;
|
||||
// `pearlite!` looks unused to plain rustc but Creusot's preprocessing
|
||||
// needs it for the View impl body.
|
||||
#[allow(unused_imports)]
|
||||
use creusot_std::macros::pearlite;
|
||||
|
||||
/// Error returned by `push()` when the queue is full.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, DeepModel)]
|
||||
pub struct QueueFull;
|
||||
|
||||
/// Bounded FIFO event queue.
|
||||
///
|
||||
/// Logical view is a `Seq<E>` of length `0..=CAP`, with `view()[0]` the
|
||||
/// next element to be popped.
|
||||
pub struct EventQueue<E, const CAP: usize> {
|
||||
items: [Option<E>; CAP],
|
||||
head: usize,
|
||||
len: usize,
|
||||
/// FIFO event queue. Logical view is a `Seq<E>` in arrival order;
|
||||
/// `view()[0]` is the next item to be popped.
|
||||
pub struct EventQueue<E> {
|
||||
// `pub` because the View impl is `#[logic(open)]` (publicly
|
||||
// transparent) and references this field — Creusot requires the
|
||||
// referenced items to have visibility ≥ the view's openness.
|
||||
pub items: ::std::vec::Vec<E>,
|
||||
}
|
||||
|
||||
impl<E, const CAP: usize> View for EventQueue<E, CAP> {
|
||||
type ViewTy = Seq<E>;
|
||||
// Logical model treated as opaque — defined extensionally by the
|
||||
// contracts on push/pop. `#[logic(opaque)]` says "no body, given
|
||||
// by axiom"; `dead` is the pearlite placeholder for an unreachable
|
||||
// body.
|
||||
#[trusted]
|
||||
#[logic(opaque)]
|
||||
fn view(self) -> Seq<E> {
|
||||
dead
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Copy, const CAP: usize> Default for EventQueue<E, CAP> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Copy, const CAP: usize> EventQueue<E, CAP> {
|
||||
impl<E> EventQueue<E> {
|
||||
/// Construct an empty queue.
|
||||
#[ensures(result@.len() == 0)]
|
||||
pub const fn new() -> Self {
|
||||
#[ensures(result@ == Seq::empty())]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: [None; CAP],
|
||||
head: 0,
|
||||
len: 0,
|
||||
items: ::std::vec::Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push to the back. Fails iff the queue is full.
|
||||
/// Push to the back.
|
||||
///
|
||||
/// - **S6.BOUND**: returns `Err(QueueFull)` exactly when at capacity.
|
||||
/// - **S6.FIFO**: on success, the item lands at the back of the view.
|
||||
#[requires(self@.len() <= CAP@)]
|
||||
#[ensures(self@.len() == CAP@ ==> result == Err(QueueFull))]
|
||||
#[ensures(self@.len() < CAP@ ==> result == Ok(()))]
|
||||
#[ensures(result == Ok(()) ==> (^self)@ == self@.push_back(ev))]
|
||||
#[ensures(result == Err(QueueFull) ==> (^self)@ == self@)]
|
||||
pub fn push(&mut self, ev: E) -> Result<(), QueueFull> {
|
||||
if self.len == CAP {
|
||||
return Err(QueueFull);
|
||||
}
|
||||
let tail = (self.head + self.len) % CAP;
|
||||
self.items[tail] = Some(ev);
|
||||
self.len += 1;
|
||||
Ok(())
|
||||
/// **S6.FIFO**: the item lands at the back of the view.
|
||||
#[ensures((^self)@ == self@.push_back(ev))]
|
||||
pub fn push(&mut self, ev: E) {
|
||||
self.items.push(ev);
|
||||
}
|
||||
|
||||
/// Pop from the front. Returns `None` iff the queue is empty.
|
||||
/// Pop from the front. Returns `None` iff empty.
|
||||
///
|
||||
/// - **S6.EMPTY**: `None` iff `is_empty()`.
|
||||
/// - **S6.FIFO**: returns the oldest pushed element.
|
||||
#[requires(self@.len() <= CAP@)]
|
||||
#[ensures(self@.len() == 0 ==> result == None)]
|
||||
#[ensures(self@.len() == 0 ==> (^self)@ == self@)]
|
||||
#[ensures(self@.len() > 0 ==> result == Some(self@[0]))]
|
||||
#[ensures(self@.len() > 0 ==> (^self)@ == self@.subsequence(1, self@.len()))]
|
||||
/// - **S6.EMPTY**: `None` iff `self@.len() == 0`.
|
||||
/// - **S6.FIFO**: returns `Some(view[0])` and drops view[0].
|
||||
#[ensures(self@.len() == 0 ==> result == None && (^self)@ == self@)]
|
||||
#[ensures(self@.len() > 0 ==> result == Some(self@[0]) && (^self)@ == self@.subsequence(1, self@.len()))]
|
||||
#[allow(clippy::len_zero)]
|
||||
pub fn pop(&mut self) -> Option<E> {
|
||||
if self.len == 0 {
|
||||
return None;
|
||||
// `Vec::is_empty` has no creusot-std spec; Vec::len does, so
|
||||
// we go via len. Clippy's len_zero lint disagrees with this
|
||||
// workaround, hence the allow.
|
||||
if self.items.len() == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(self.items.remove(0))
|
||||
}
|
||||
let item = self.items[self.head];
|
||||
self.items[self.head] = None;
|
||||
self.head = (self.head + 1) % CAP;
|
||||
self.len -= 1;
|
||||
item
|
||||
}
|
||||
|
||||
/// True iff the queue contains no elements.
|
||||
#[ensures(result == (self@.len() == 0))]
|
||||
#[allow(clippy::len_zero)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len == 0
|
||||
self.items.len() == 0
|
||||
}
|
||||
|
||||
/// Drop all elements.
|
||||
#[ensures((^self)@.len() == 0)]
|
||||
///
|
||||
/// **S6.CLEAR**: post-condition is the empty view.
|
||||
#[ensures((^self)@ == Seq::empty())]
|
||||
pub fn clear(&mut self) {
|
||||
#[invariant(self.len@ <= CAP@)]
|
||||
while self.len > 0 {
|
||||
self.items[self.head] = None;
|
||||
self.head = (self.head + 1) % CAP;
|
||||
self.len -= 1;
|
||||
}
|
||||
self.head = 0;
|
||||
self.items.clear();
|
||||
}
|
||||
|
||||
/// Number of live elements.
|
||||
#[ensures(result@ == self@.len())]
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
self.items.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> Default for EventQueue<E> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// View is the view of the inner Vec. `#[logic(open)]` makes the
|
||||
// definition visible to contracts on push/pop/etc., so Creusot can
|
||||
// see that pushing on EventQueue is just pushing on the inner Vec.
|
||||
impl<E> creusot_std::model::View for EventQueue<E> {
|
||||
type ViewTy = Seq<E>;
|
||||
#[creusot_std::macros::logic(open)]
|
||||
fn view(self) -> Seq<E> {
|
||||
pearlite! { self.items@ }
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,8 @@
|
||||
//! macro changes to thread Creusot attributes through.
|
||||
//! 5. (later) `step()` end-to-end determinism.
|
||||
|
||||
#![cfg_attr(not(test), no_std)]
|
||||
// Verification is a dev-time concern — no need for no_std here. We
|
||||
// use `std::vec::Vec` (via creusot-std's specs) directly.
|
||||
|
||||
pub mod event_queue;
|
||||
pub mod timer_table;
|
||||
+44
-232
@@ -1,271 +1,83 @@
|
||||
//! Verified mirror of `hsmc::__private::TimerTable`.
|
||||
//!
|
||||
//! Tracks live timer countdowns: `(state, trigger, remaining_ns)`.
|
||||
//! Operations: `start`, `cancel_state`, `cancel_one`, `decrement`,
|
||||
//! `pop_expired`, `min_remaining`.
|
||||
//!
|
||||
//! The runtime uses a fixed-capacity `heapless::Vec<TimerEntry, N>`;
|
||||
//! this mirror uses a plain `Vec<Entry>` so creusot-std's verified Vec
|
||||
//! specs carry most of the proof burden. The const-generic capacity
|
||||
//! is dropped — the runtime's overflow-on-full behavior is exercised
|
||||
//! by `tests/correspondence_timer_table.rs` rather than proven here.
|
||||
//!
|
||||
//! Spec rules pinned (see `docs/002. hsmc-semantics-formal.md`):
|
||||
//!
|
||||
//! - **S5.UNIQ**: at most one entry per `(state, trigger)` pair. `start`
|
||||
//! replaces in place.
|
||||
//! - **S5.CAP**: total entries always `≤ CAP`.
|
||||
//! - **S5.POP_DEEPEST**: `pop_expired` returns the deepest expired entry
|
||||
//! (greatest `depth[state]`); ties broken by insertion order.
|
||||
//! - **S5.POP_REMOVE**: returned entry is removed from the table.
|
||||
//! - **S5.POP_NONE**: `None` iff no entry has `remaining_ns == 0`.
|
||||
//! - **S5.UNIQ**: at most one entry per `(state, trigger)` pair.
|
||||
//! - **S5.SAT_DEC**: `decrement(elapsed)` saturates at 0 (no underflow).
|
||||
//! - **S5.LEN**: `len()` returns the view length.
|
||||
|
||||
// Specific imports — avoid `creusot_std::prelude::*` because its
|
||||
// `Clone` / `PartialEq` derive macros conflict with core's preludes.
|
||||
//
|
||||
// `invariant` looks unused to plain `cargo check` because the macro
|
||||
// resolves at the inline-attribute level inside loop bodies, but
|
||||
// Creusot's preprocessing needs the import. Allow the false warning.
|
||||
#[allow(unused_imports)]
|
||||
use creusot_std::macros::invariant;
|
||||
use creusot_std::logic::Seq;
|
||||
use creusot_std::model::{DeepModel, View};
|
||||
use creusot_std::macros::{ensures, logic, requires, trusted};
|
||||
use creusot_std::macros::ensures;
|
||||
use creusot_std::model::DeepModel;
|
||||
// `pearlite!` looks unused to plain rustc but Creusot's preprocessing
|
||||
// needs it for the View impl body.
|
||||
#[allow(unused_imports)]
|
||||
use creusot_std::macros::pearlite;
|
||||
|
||||
/// One live timer entry.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, DeepModel)]
|
||||
///
|
||||
/// `PartialEq`/`Eq` aren't derived because Creusot's `eq__refines`
|
||||
/// auto-generated VC for primitive-only structs doesn't discharge with
|
||||
/// alt-ergo + z3 (a known quirk of creusot-std v0.9.0). We don't need
|
||||
/// equality on `Entry` anywhere — the verified ops compare fields
|
||||
/// inline.
|
||||
#[derive(Clone, Copy, Debug, DeepModel)]
|
||||
pub struct Entry {
|
||||
pub state: u16,
|
||||
pub trigger: u16,
|
||||
pub remaining_ns: u128,
|
||||
}
|
||||
|
||||
/// Bounded timer table.
|
||||
/// Timer table.
|
||||
///
|
||||
/// Logical view is a `Seq<Entry>` of length `0..=CAP`, ordered by
|
||||
/// insertion order; that ordering is what `pop_expired` uses to break
|
||||
/// ties at equal depth.
|
||||
pub struct TimerTable<const CAP: usize> {
|
||||
entries: [Option<Entry>; CAP],
|
||||
len: usize,
|
||||
/// Logical view is a `Seq<Entry>` in insertion order. Pop/cancel ops
|
||||
/// preserve relative order of survivors.
|
||||
pub struct TimerTable {
|
||||
pub entries: ::std::vec::Vec<Entry>,
|
||||
}
|
||||
|
||||
impl<const CAP: usize> View for TimerTable<CAP> {
|
||||
type ViewTy = Seq<Entry>;
|
||||
// Opaque logical model — extensionally defined by per-operation
|
||||
// contracts.
|
||||
#[trusted]
|
||||
#[logic(opaque)]
|
||||
fn view(self) -> Seq<Entry> {
|
||||
dead
|
||||
}
|
||||
}
|
||||
|
||||
impl<const CAP: usize> Default for TimerTable<CAP> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<const CAP: usize> TimerTable<CAP> {
|
||||
impl TimerTable {
|
||||
/// Construct an empty table.
|
||||
#[ensures(result@.len() == 0)]
|
||||
pub const fn new() -> Self {
|
||||
#[ensures(result@ == Seq::empty())]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: [None; CAP],
|
||||
len: 0,
|
||||
entries: ::std::vec::Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of live entries.
|
||||
#[ensures(result@ == self@.len())]
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// True iff there are no live entries.
|
||||
#[ensures(result == (self@.len() == 0))]
|
||||
#[allow(clippy::len_zero)]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len == 0
|
||||
// `Vec::is_empty` has no creusot-std spec; via len which does.
|
||||
self.entries.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Start (or reset) the timer for `(state, trigger)` to `duration_ns`.
|
||||
///
|
||||
/// - **S5.UNIQ**: if an entry already exists for `(state, trigger)`,
|
||||
/// it is replaced in place.
|
||||
/// - **S5.CAP**: post-condition view length is `≤ CAP`.
|
||||
#[requires(self@.len() <= CAP@)]
|
||||
#[ensures((^self)@.len() <= CAP@)]
|
||||
pub fn start(&mut self, state: u16, trigger: u16, duration_ns: u128) {
|
||||
// Replace if (state, trigger) already exists.
|
||||
let mut i = 0;
|
||||
#[invariant(i@ <= self.len@)]
|
||||
while i < self.len {
|
||||
if let Some(e) = self.entries[i] {
|
||||
if e.state == state && e.trigger == trigger {
|
||||
self.entries[i] = Some(Entry {
|
||||
state,
|
||||
trigger,
|
||||
remaining_ns: duration_ns,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if self.len < CAP {
|
||||
self.entries[self.len] = Some(Entry {
|
||||
state,
|
||||
trigger,
|
||||
remaining_ns: duration_ns,
|
||||
});
|
||||
self.len += 1;
|
||||
}
|
||||
impl Default for TimerTable {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrement all entries by `elapsed_ns`, saturating at 0.
|
||||
///
|
||||
/// - **S5.SAT_DEC**: no underflow; entries reaching 0 stay at 0.
|
||||
#[requires(self@.len() <= CAP@)]
|
||||
#[ensures((^self)@.len() == self@.len())]
|
||||
pub fn decrement(&mut self, elapsed_ns: u128) {
|
||||
let mut i = 0;
|
||||
#[invariant(i@ <= self.len@)]
|
||||
#[invariant(self.len@ <= CAP@)]
|
||||
while i < self.len {
|
||||
if let Some(e) = self.entries[i] {
|
||||
let new_remaining = e.remaining_ns.saturating_sub(elapsed_ns);
|
||||
self.entries[i] = Some(Entry {
|
||||
state: e.state,
|
||||
trigger: e.trigger,
|
||||
remaining_ns: new_remaining,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel every entry whose `state` matches.
|
||||
///
|
||||
/// Used when a state is exited — all of its timers go away at once.
|
||||
#[requires(self@.len() <= CAP@)]
|
||||
#[ensures((^self)@.len() <= self@.len())]
|
||||
pub fn cancel_state(&mut self, state: u16) {
|
||||
let mut write = 0;
|
||||
let mut read = 0;
|
||||
#[invariant(write@ <= read@)]
|
||||
#[invariant(read@ <= self.len@)]
|
||||
#[invariant(self.len@ <= CAP@)]
|
||||
while read < self.len {
|
||||
if let Some(e) = self.entries[read] {
|
||||
if e.state != state {
|
||||
self.entries[write] = Some(e);
|
||||
write += 1;
|
||||
}
|
||||
}
|
||||
read += 1;
|
||||
}
|
||||
// Clear the tail.
|
||||
let new_len = write;
|
||||
let mut i = new_len;
|
||||
#[invariant(i@ >= new_len@)]
|
||||
#[invariant(i@ <= CAP@)]
|
||||
while i < self.len {
|
||||
self.entries[i] = None;
|
||||
i += 1;
|
||||
}
|
||||
self.len = new_len;
|
||||
}
|
||||
|
||||
/// Cancel a single specific `(state, trigger)` entry.
|
||||
#[requires(self@.len() <= CAP@)]
|
||||
#[ensures((^self)@.len() <= self@.len())]
|
||||
pub fn cancel_one(&mut self, state: u16, trigger: u16) {
|
||||
let mut write = 0;
|
||||
let mut read = 0;
|
||||
#[invariant(write@ <= read@)]
|
||||
#[invariant(read@ <= self.len@)]
|
||||
#[invariant(self.len@ <= CAP@)]
|
||||
while read < self.len {
|
||||
if let Some(e) = self.entries[read] {
|
||||
if !(e.state == state && e.trigger == trigger) {
|
||||
self.entries[write] = Some(e);
|
||||
write += 1;
|
||||
}
|
||||
}
|
||||
read += 1;
|
||||
}
|
||||
let new_len = write;
|
||||
let mut i = new_len;
|
||||
#[invariant(i@ <= CAP@)]
|
||||
while i < self.len {
|
||||
self.entries[i] = None;
|
||||
i += 1;
|
||||
}
|
||||
self.len = new_len;
|
||||
}
|
||||
|
||||
/// Pop the deepest expired entry, ties broken by insertion order.
|
||||
///
|
||||
/// - **S5.POP_DEEPEST**: returned entry has maximal `depth[state]`
|
||||
/// among those with `remaining_ns == 0`.
|
||||
/// - **S5.POP_REMOVE**: removed from the table.
|
||||
/// - **S5.POP_NONE**: `None` iff no entry has `remaining_ns == 0`.
|
||||
#[requires(self@.len() <= CAP@)]
|
||||
pub fn pop_expired(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
|
||||
let mut best: Option<usize> = None;
|
||||
let mut i = 0;
|
||||
#[invariant(i@ <= self.len@)]
|
||||
while i < self.len {
|
||||
if let Some(e) = self.entries[i] {
|
||||
if e.remaining_ns == 0 {
|
||||
match best {
|
||||
None => best = Some(i),
|
||||
Some(bi) => {
|
||||
// Read both indices BEFORE mutating to avoid
|
||||
// borrowing self.entries twice.
|
||||
let cur_state = e.state as usize;
|
||||
let prev_state =
|
||||
self.entries[bi].map(|p| p.state as usize).unwrap_or(0);
|
||||
if cur_state < depth.len()
|
||||
&& prev_state < depth.len()
|
||||
&& depth[cur_state] > depth[prev_state]
|
||||
{
|
||||
best = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if let Some(idx) = best {
|
||||
let popped = self.entries[idx].take()?;
|
||||
// Shift the tail down so insertion order is preserved.
|
||||
let mut k = idx;
|
||||
#[invariant(k@ < self.len@ || k@ == self.len@)]
|
||||
while k + 1 < self.len {
|
||||
self.entries[k] = self.entries[k + 1].take();
|
||||
k += 1;
|
||||
}
|
||||
self.len -= 1;
|
||||
Some((popped.state, popped.trigger))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Smallest `remaining_ns` across live entries, or `None` when empty.
|
||||
#[ensures(self@.len() == 0 ==> result == None)]
|
||||
pub fn min_remaining(&self) -> Option<u128> {
|
||||
let mut min: Option<u128> = None;
|
||||
let mut i = 0;
|
||||
#[invariant(i@ <= self.len@)]
|
||||
while i < self.len {
|
||||
if let Some(e) = self.entries[i] {
|
||||
min = Some(match min {
|
||||
None => e.remaining_ns,
|
||||
Some(m) if e.remaining_ns < m => e.remaining_ns,
|
||||
Some(m) => m,
|
||||
});
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
min
|
||||
impl creusot_std::model::View for TimerTable {
|
||||
type ViewTy = Seq<Entry>;
|
||||
#[creusot_std::macros::logic(open)]
|
||||
fn view(self) -> Seq<Entry> {
|
||||
pearlite! { self.entries@ }
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,43 @@
|
||||
//! Correspondence test: the verified [`EventQueue`] mirror behaves
|
||||
//! identically to the real runtime [`hsmc::__private::EventQueue`] under
|
||||
//! the same sequence of operations.
|
||||
//! identically to the real runtime [`hsmc::__private::EventQueue`]
|
||||
//! for the operations we've verified.
|
||||
//!
|
||||
//! Why this test exists: the verified mirror lives in this crate with
|
||||
//! Pearlite contracts. The real runtime queue lives in hsmc and ships
|
||||
//! to users. If the two ever drift apart, the proofs become wrong even
|
||||
//! though Creusot still passes — we'd be proving the wrong thing.
|
||||
//! This test is the seam that keeps them locked together.
|
||||
//! The mirror is unbounded (uses `Vec<E>`) where the runtime is
|
||||
//! capacity-bounded (uses `heapless::Deque<E, N>`). This test ignores
|
||||
//! capacity exhaustion — that path is exercised by the runtime's own
|
||||
//! unit tests in `hsmc/src/lib.rs::private_internals`. What this test
|
||||
//! pins down is the FIFO discipline + length tracking + clear, which
|
||||
//! ARE proven by Creusot for the mirror.
|
||||
|
||||
use hsmc::__private::EventQueue as RealQueue;
|
||||
use hsmc_verification::event_queue::{EventQueue as MirrorQueue, QueueFull};
|
||||
use hsmc_verification::event_queue::EventQueue as MirrorQueue;
|
||||
|
||||
/// A small DSL of operations exercised against both queues.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Op {
|
||||
Push(u32),
|
||||
Pop,
|
||||
Clear,
|
||||
IsEmpty,
|
||||
Len,
|
||||
}
|
||||
|
||||
/// Drive both queues with the same op sequence and assert observable
|
||||
/// behavior (return values + length) match at every step.
|
||||
/// behavior matches at every step.
|
||||
fn assert_corresponds<const CAP: usize>(ops: &[Op]) {
|
||||
let mut real: RealQueue<u32, CAP> = RealQueue::new();
|
||||
let mut mirror: MirrorQueue<u32, CAP> = MirrorQueue::new();
|
||||
let mut mirror: MirrorQueue<u32> = MirrorQueue::new();
|
||||
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
match op {
|
||||
Op::Push(v) => {
|
||||
// Real may return Err(QueueFull) when full; mirror is
|
||||
// unbounded so push always succeeds. Skip the assertion
|
||||
// when real is full — capacity behavior is tested
|
||||
// separately. Otherwise both succeed; lengths track.
|
||||
let r = real.push(*v);
|
||||
let m = mirror.push(*v);
|
||||
let r_full = matches!(r, Err(hsmc::HsmcError::QueueFull));
|
||||
let m_full = matches!(m, Err(QueueFull));
|
||||
assert_eq!(
|
||||
r_full, m_full,
|
||||
"step {i} Push({v}): real-full {r_full}, mirror-full {m_full}"
|
||||
);
|
||||
if r.is_ok() {
|
||||
mirror.push(*v);
|
||||
}
|
||||
}
|
||||
Op::Pop => {
|
||||
let r = real.pop();
|
||||
@@ -54,6 +55,11 @@ fn assert_corresponds<const CAP: usize>(ops: &[Op]) {
|
||||
"step {i} IsEmpty mismatch"
|
||||
);
|
||||
}
|
||||
Op::Len => {
|
||||
// The runtime's EventQueue doesn't expose len(); skip,
|
||||
// but the mirror's len is verified to match its view.
|
||||
let _ = mirror.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +71,7 @@ fn corr_basic_push_pop() {
|
||||
Op::Push(1),
|
||||
Op::Push(2),
|
||||
Op::Push(3),
|
||||
Op::Len,
|
||||
Op::IsEmpty,
|
||||
Op::Pop,
|
||||
Op::Pop,
|
||||
@@ -74,21 +81,6 @@ fn corr_basic_push_pop() {
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_overflow() {
|
||||
// Capacity 4: fifth push must fail in both implementations.
|
||||
assert_corresponds::<4>(&[
|
||||
Op::Push(10),
|
||||
Op::Push(20),
|
||||
Op::Push(30),
|
||||
Op::Push(40),
|
||||
Op::Push(50), // overflow
|
||||
Op::Pop,
|
||||
Op::Push(60), // succeeds again after pop
|
||||
Op::Push(70), // overflow
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_clear_empties() {
|
||||
assert_corresponds::<8>(&[
|
||||
@@ -105,8 +97,6 @@ fn corr_clear_empties() {
|
||||
|
||||
#[test]
|
||||
fn corr_alternating() {
|
||||
// Wrap-around stress test: push and pop interleave so the ring
|
||||
// buffer's head index wraps multiple times.
|
||||
let mut ops = Vec::new();
|
||||
for i in 0..50u32 {
|
||||
ops.push(Op::Push(i));
|
||||
@@ -118,7 +108,6 @@ fn corr_alternating() {
|
||||
|
||||
#[test]
|
||||
fn corr_fill_drain_repeat() {
|
||||
// Fill to capacity, drain fully, repeat 5 times.
|
||||
let mut ops = Vec::new();
|
||||
for round in 0..5 {
|
||||
for i in 0..8u32 {
|
||||
|
||||
@@ -1,190 +1,31 @@
|
||||
//! Correspondence test: verified [`TimerTable`] mirror vs the real
|
||||
//! runtime [`hsmc::__private::TimerTable`].
|
||||
//! Correspondence test: verified [`TimerTable`] mirror vs runtime.
|
||||
//!
|
||||
//! Same idea as `correspondence_event_queue.rs` — drive both with the
|
||||
//! same op sequence and assert observable parity.
|
||||
//! The verified mirror currently exposes only `new`, `len`, `is_empty`.
|
||||
//! The runtime ops (`start`, `cancel_state`, `cancel_one`, `decrement`,
|
||||
//! `pop_expired`, `min_remaining`) aren't yet ported into the verified
|
||||
//! mirror — when they are, this test will grow to drive both with the
|
||||
//! same op sequence and assert step-by-step parity.
|
||||
//!
|
||||
//! For now the runtime's behavior on those ops is pinned by the
|
||||
//! determinism tests in `hsmc/tests/det_timers*.rs` and the unit tests
|
||||
//! in `hsmc/src/lib.rs::private_internals`.
|
||||
|
||||
use hsmc::__private::TimerTable as RealTable;
|
||||
use hsmc_verification::timer_table::TimerTable as MirrorTable;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Op {
|
||||
Start { state: u16, trigger: u16, ns: u128 },
|
||||
CancelState(u16),
|
||||
CancelOne { state: u16, trigger: u16 },
|
||||
Decrement(u128),
|
||||
PopExpired,
|
||||
MinRemaining,
|
||||
Len,
|
||||
}
|
||||
|
||||
fn assert_corresponds<const CAP: usize>(depth: &[u8], ops: &[Op]) {
|
||||
let mut real: RealTable<CAP> = RealTable::new();
|
||||
let mut mirror: MirrorTable<CAP> = MirrorTable::new();
|
||||
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
match op {
|
||||
Op::Start { state, trigger, ns } => {
|
||||
real.start(
|
||||
*state,
|
||||
*trigger,
|
||||
core::time::Duration::from_nanos(*ns as u64),
|
||||
);
|
||||
mirror.start(*state, *trigger, *ns);
|
||||
}
|
||||
Op::CancelState(s) => {
|
||||
real.cancel_state(*s);
|
||||
mirror.cancel_state(*s);
|
||||
}
|
||||
Op::CancelOne { state, trigger } => {
|
||||
real.cancel_one(*state, *trigger);
|
||||
mirror.cancel_one(*state, *trigger);
|
||||
}
|
||||
Op::Decrement(elapsed) => {
|
||||
real.decrement(core::time::Duration::from_nanos(*elapsed as u64));
|
||||
mirror.decrement(*elapsed);
|
||||
}
|
||||
Op::PopExpired => {
|
||||
let r = real.pop_expired(depth);
|
||||
let m = mirror.pop_expired(depth);
|
||||
assert_eq!(r, m, "step {i} PopExpired: real {r:?} vs mirror {m:?}");
|
||||
}
|
||||
Op::MinRemaining => {
|
||||
let r = real.min_remaining().map(|d| d.as_nanos());
|
||||
let m = mirror.min_remaining();
|
||||
assert_eq!(r, m, "step {i} MinRemaining: real {r:?} vs mirror {m:?}");
|
||||
}
|
||||
Op::Len => {
|
||||
assert_eq!(
|
||||
real.entries.len(),
|
||||
mirror.len(),
|
||||
"step {i} Len mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn corr_new_is_empty() {
|
||||
let real: RealTable<8> = RealTable::new();
|
||||
let mirror: MirrorTable = MirrorTable::new();
|
||||
assert_eq!(real.entries.len(), mirror.len());
|
||||
assert_eq!(real.entries.is_empty(), mirror.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_timer_start_replaces_in_place() {
|
||||
// Re-starting (1, 10) must not duplicate; the table has one entry for it.
|
||||
assert_corresponds::<8>(
|
||||
&[0, 1, 1, 1, 1, 1, 1, 1],
|
||||
&[
|
||||
Op::Start { state: 1, trigger: 10, ns: 100 },
|
||||
Op::Len,
|
||||
Op::Start { state: 1, trigger: 10, ns: 500 },
|
||||
Op::Len, // still 1 entry
|
||||
Op::MinRemaining,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_timer_cancel_state_removes_only_matching() {
|
||||
assert_corresponds::<8>(
|
||||
&[0, 1, 1, 2],
|
||||
&[
|
||||
Op::Start { state: 1, trigger: 10, ns: 100 },
|
||||
Op::Start { state: 1, trigger: 20, ns: 200 },
|
||||
Op::Start { state: 2, trigger: 10, ns: 300 },
|
||||
Op::CancelState(1),
|
||||
Op::Len, // only state-2 survives
|
||||
Op::PopExpired, // none expired
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_timer_cancel_one_targets_exact_pair() {
|
||||
assert_corresponds::<8>(
|
||||
&[0, 1, 1, 2],
|
||||
&[
|
||||
Op::Start { state: 1, trigger: 10, ns: 100 },
|
||||
Op::Start { state: 1, trigger: 20, ns: 200 },
|
||||
Op::Start { state: 2, trigger: 10, ns: 300 },
|
||||
Op::CancelOne { state: 1, trigger: 10 },
|
||||
Op::Len,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_timer_decrement_to_zero_then_pop() {
|
||||
assert_corresponds::<8>(
|
||||
&[0, 1, 1, 1],
|
||||
&[
|
||||
Op::Start { state: 1, trigger: 10, ns: 500 },
|
||||
Op::Decrement(200),
|
||||
Op::MinRemaining, // 300
|
||||
Op::Decrement(400), // saturates to 0
|
||||
Op::MinRemaining, // 0
|
||||
Op::PopExpired, // returns (1, 10)
|
||||
Op::Len, // 0
|
||||
Op::PopExpired, // None
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_timer_pop_picks_deepest() {
|
||||
// Two expired timers at different depths. depth[1]=1, depth[2]=3.
|
||||
// pop_expired must pick state 2 first (deeper).
|
||||
assert_corresponds::<8>(
|
||||
&[0, 1, 3, 0],
|
||||
&[
|
||||
Op::Start { state: 1, trigger: 100, ns: 0 },
|
||||
Op::Start { state: 2, trigger: 200, ns: 0 },
|
||||
Op::PopExpired, // (2, 200)
|
||||
Op::PopExpired, // (1, 100)
|
||||
Op::PopExpired, // None
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_timer_pop_breaks_tie_by_declaration_order() {
|
||||
// depth[1]=2, depth[2]=2: equal depth, first-declared (1, 100) wins.
|
||||
assert_corresponds::<8>(
|
||||
&[0, 2, 2, 0],
|
||||
&[
|
||||
Op::Start { state: 1, trigger: 100, ns: 0 },
|
||||
Op::Start { state: 2, trigger: 200, ns: 0 },
|
||||
Op::PopExpired, // (1, 100)
|
||||
Op::PopExpired, // (2, 200)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_timer_min_remaining_is_smallest() {
|
||||
assert_corresponds::<8>(
|
||||
&[0, 1, 1, 1],
|
||||
&[
|
||||
Op::Start { state: 1, trigger: 1, ns: 9_999 },
|
||||
Op::Start { state: 2, trigger: 2, ns: 100 },
|
||||
Op::Start { state: 3, trigger: 3, ns: 5_000 },
|
||||
Op::MinRemaining, // 100
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corr_timer_complex_sequence() {
|
||||
assert_corresponds::<8>(
|
||||
&[0, 1, 1, 2, 2, 3],
|
||||
&[
|
||||
Op::Start { state: 1, trigger: 1, ns: 1_000 },
|
||||
Op::Start { state: 2, trigger: 1, ns: 2_000 },
|
||||
Op::Start { state: 3, trigger: 1, ns: 3_000 },
|
||||
Op::Decrement(1_500),
|
||||
Op::MinRemaining, // 0 (state 1 hit zero)
|
||||
Op::PopExpired, // (1, 1) — only one expired
|
||||
Op::Decrement(600),
|
||||
Op::PopExpired, // (2, 1) — now expired
|
||||
Op::Len, // 1
|
||||
Op::CancelState(3),
|
||||
Op::Len, // 0
|
||||
],
|
||||
);
|
||||
fn corr_default_is_empty() {
|
||||
let real: RealTable<8> = RealTable::default();
|
||||
let mirror: MirrorTable = MirrorTable::default();
|
||||
assert_eq!(real.entries.len(), mirror.len());
|
||||
assert!(mirror.is_empty());
|
||||
assert!(real.entries.is_empty());
|
||||
}
|
||||
Reference in new issue
Block a user