Skip to main content

hegel/
stateful.rs

1//! Stateful (model-based) testing support.
2//!
3//! State machines are defined using the [`state_machine`](crate::state_machine) attribute macro.
4//! Methods annotated with `#[rule]` become rules (actions applied to the state machine) and
5//! methods annotated with `#[invariant]` become invariants (checked after each successful rule
6//! application). Rules must have signature `fn(&mut self, tc: TestCase)` and invariants must have
7//! signature `fn(&self, tc: TestCase)`.
8//!
9//! To run a state machine, call [`run()`] inside a Hegel test.
10//!
11//! Example:
12//! ```rust
13//! use hegel::TestCase;
14//! use hegel::generators as gs;
15//!
16//! struct IntegerStack {
17//!     stack: Vec<i32>,
18//! }
19//!
20//! #[hegel::state_machine]
21//! impl IntegerStack {
22//!     #[rule]
23//!     fn push(&mut self, tc: TestCase) {
24//!         let integers = gs::integers::<i32>;
25//!         let element = tc.draw(integers());
26//!         self.stack.push(element);
27//!     }
28//!
29//!     #[rule]
30//!     fn pop(&mut self, _: TestCase) {
31//!         self.stack.pop();
32//!     }
33//!
34//!     #[rule]
35//!     fn pop_push(&mut self, tc: TestCase) {
36//!         let integers = gs::integers::<i32>;
37//!         let element = tc.draw(integers());
38//!         let initial = self.stack.clone();
39//!         self.stack.push(element);
40//!         let popped = self.stack.pop().unwrap();
41//!         assert_eq!(popped, element);
42//!         assert_eq!(self.stack, initial);
43//!     }
44//!
45//!     #[rule]
46//!     fn push_pop(&mut self, tc: TestCase) {
47//!         let initial = self.stack.clone();
48//!         let element = self.stack.pop();
49//!         tc.assume(element.is_some());
50//!         let element = element.unwrap();
51//!         self.stack.push(element);
52//!         assert_eq!(self.stack, initial);
53//!     }
54//! }
55//!
56//! #[hegel::test]
57//! fn test_integer_stack(tc: TestCase) {
58//!     let stack = IntegerStack { stack: Vec::new() };
59//!     hegel::stateful::run(stack, tc);
60//! }
61//! ```
62
63use crate::TestCase;
64use crate::control::{AssumeFailed, hegel_internal_assert};
65use crate::generators::{Generator, integers};
66use crate::runner::Mode;
67use crate::test_case::raise_for_rc;
68use std::cell::RefCell;
69use std::cmp::min;
70use std::collections::HashMap;
71use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
72
73/// A rule that can be applied to the state machine during testing.
74pub struct Rule<M: ?Sized> {
75    pub name: String,
76    pub apply: fn(&mut M, TestCase),
77}
78
79impl<M> Rule<M> {
80    /// Create a new rule with a name and an apply function.
81    pub fn new(name: &str, apply: fn(&mut M, TestCase)) -> Self {
82        Rule {
83            name: name.to_string(),
84            apply,
85        }
86    }
87}
88
89/// A pool of previously generated values.
90///
91/// Create one with [`pool()`] and populate it with [`add`](Pool::add). To draw
92/// from the pool, use the generators it hands out rather than reading from it
93/// directly:
94///
95/// - [`values_reusable`](Pool::values_reusable) returns a generator over `&T` —
96///   drawing from it yields a reference to a value in the pool without removing
97///   it.
98/// - [`values_consumed`](Pool::values_consumed) returns a generator over `T` —
99///   drawing from it removes a value from the pool and yields it by value.
100///
101/// Both generators are used through [`tc.draw`](TestCase::draw), so the chosen
102/// value is recorded in the failing-test replay and the choice shrinks like any
103/// other draw.
104pub struct Pool<T> {
105    pool_id: i64,
106    tc: TestCase,
107    values: HashMap<i64, T>,
108}
109
110/// Ask the engine for a variable id from `pool_id`, consuming it if `consume`.
111fn pool_generate(tc: &TestCase, pool_id: i64, consume: bool) -> i64 {
112    match tc.with_ctc(|ctc| ctc.pool_generate(pool_id, consume)) {
113        Ok(id) => id,
114        Err(rc) => raise_for_rc(rc),
115    }
116}
117
118impl<T> Pool<T> {
119    /// Returns true if no values are in the pool.
120    pub fn is_empty(&self) -> bool {
121        self.values.is_empty()
122    }
123
124    /// Number of values currently in the pool.
125    pub fn len(&self) -> usize {
126        self.values.len()
127    }
128
129    /// Add a value to the pool.
130    pub fn add(&mut self, v: T) {
131        let variable_id: i64 = match self.tc.with_ctc(|ctc| ctc.pool_add(self.pool_id)) {
132            Ok(id) => id,
133            Err(rc) => raise_for_rc(rc), // nocov
134        };
135        if self.values.contains_key(&variable_id) {
136            panic!("unexpected variable id in map"); // nocov
137        }
138        self.values.insert(variable_id, v);
139    }
140
141    /// A generator over references to values in the pool.
142    ///
143    /// Drawing from it yields a `&T` borrowing a value in the pool, without
144    /// removing it. Drawing rejects the current test case (as if by
145    /// `assume(false)`) when the pool is empty.
146    pub fn values_reusable(&self) -> ValuesReusable<'_, T> {
147        ValuesReusable {
148            pool_id: self.pool_id,
149            values: &self.values,
150        }
151    }
152
153    /// A generator that consumes values from the pool.
154    ///
155    /// Drawing from it removes a value from the pool and yields it by value.
156    /// Once consumed, that value is never drawn again. Drawing rejects the
157    /// current test case (as if by `assume(false)`) when the pool is empty.
158    pub fn values_consumed(&mut self) -> ValuesConsumed<'_, T> {
159        ValuesConsumed {
160            pool_id: self.pool_id,
161            values: RefCell::new(&mut self.values),
162        }
163    }
164}
165
166/// A generator over references to the values in a [`Pool`].
167///
168/// Returned by [`Pool::values_reusable`]. Borrows the pool, so the references it
169/// produces stay valid for as long as the generator is alive.
170pub struct ValuesReusable<'a, T> {
171    pool_id: i64,
172    values: &'a HashMap<i64, T>,
173}
174
175impl<'a, T> Generator<&'a T> for ValuesReusable<'a, T> {
176    fn do_draw(&self, tc: &TestCase) -> &'a T {
177        tc.assume(!self.values.is_empty());
178        let variable_id = pool_generate(tc, self.pool_id, false);
179        self.values.get(&variable_id).unwrap()
180    }
181}
182
183/// A generator that consumes values from a [`Pool`], removing each value it
184/// yields.
185///
186/// Returned by [`Pool::values_consumed`]. Borrows the pool mutably; the inner
187/// [`RefCell`] is what lets it remove a value during a draw, which only has
188/// shared access to the generator.
189pub struct ValuesConsumed<'a, T> {
190    pool_id: i64,
191    values: RefCell<&'a mut HashMap<i64, T>>,
192}
193
194impl<T> Generator<T> for ValuesConsumed<'_, T> {
195    fn do_draw(&self, tc: &TestCase) -> T {
196        tc.assume(!self.values.borrow().is_empty());
197        let variable_id = pool_generate(tc, self.pool_id, true);
198        self.values.borrow_mut().remove(&variable_id).unwrap()
199    }
200}
201
202/// Create a new value pool for stateful tests.
203pub fn pool<T>(tc: &TestCase) -> Pool<T> {
204    let pool_id = match tc.with_ctc(|ctc| ctc.new_pool()) {
205        Ok(id) => id,
206        Err(rc) => raise_for_rc(rc), // nocov
207    };
208    Pool {
209        pool_id,
210        tc: tc.clone(),
211        values: HashMap::new(),
212    }
213}
214
215/// Trait for defining a stateful test.
216///
217/// Implement this to define the rules (actions) and invariants (assertions)
218/// of your state machine. Use `#[hegel::state_machine]` for a more
219/// ergonomic way to define state machines.
220pub trait StateMachine {
221    /// The rules (actions) that can be applied to this state machine.
222    fn rules(&self) -> Vec<Rule<Self>>;
223    /// Invariants checked after each successful rule application.
224    fn invariants(&self) -> Vec<Rule<Self>>;
225}
226
227fn check_invariants<M: StateMachine>(m: &mut M, invariants: &[Rule<M>], tc: &TestCase) {
228    for invariant in invariants {
229        let inv_tc = tc.child(2); // nocov
230        (invariant.apply)(m, inv_tc); // nocov
231    }
232}
233
234/// Execute a stateful test by repeatedly applying random rules and checking invariants.
235pub fn run<M: StateMachine>(mut m: M, tc: TestCase) {
236    let rules = m.rules();
237    let rule_names: Vec<&str> = rules.iter().map(|r| r.name.as_str()).collect();
238    let invariants = m.invariants();
239    let invariant_names: Vec<&str> = invariants.iter().map(|r| r.name.as_str()).collect();
240    let machine_id = match tc.with_ctc(|ctc| ctc.new_state_machine(&rule_names, &invariant_names)) {
241        Ok(id) => id,
242        Err(rc) => raise_for_rc(rc),
243    };
244
245    tc.note("Initial invariant check.");
246    check_invariants(&mut m, &invariants, &tc);
247
248    let is_single = tc.mode() == Mode::SingleTestCase;
249
250    let step_cap = if is_single {
251        i64::MAX
252    } else {
253        let max_steps = 50;
254        let unbounded_step_cap = tc.draw_silent(integers::<i64>().min_value(1));
255        min(unbounded_step_cap, max_steps)
256    };
257
258    let mut steps_run_successfully = 0;
259    let mut steps_attempted = 0;
260    let mut step = 0;
261
262    while steps_run_successfully < step_cap
263        && (is_single
264            || steps_attempted < 10 * step_cap
265            || (steps_run_successfully == 0 && steps_attempted < 1000))
266    {
267        step += 1;
268        let rule_index = match tc.with_ctc(|ctc| ctc.state_machine_next_rule(machine_id)) {
269            Ok(i) => i,
270            Err(rc) => raise_for_rc(rc),
271        };
272        hegel_internal_assert!(
273            (0..rules.len() as i64).contains(&rule_index),
274            "state_machine_next_rule returned out-of-range rule index {rule_index}"
275        );
276        let rule = &rules[rule_index as usize];
277        tc.note(&format!("Step {}: {}", step, rule.name));
278
279        let rule_tc = tc.child(2);
280        let thunk = || (rule.apply)(&mut m, rule_tc);
281        let result = catch_unwind(AssertUnwindSafe(thunk));
282
283        steps_attempted += 1;
284        match result {
285            Ok(()) => {
286                steps_run_successfully += 1;
287                check_invariants(&mut m, &invariants, &tc);
288            }
289            Err(e) if e.downcast_ref::<AssumeFailed>().is_some() => {
290                tc.note("Rule stopped early due to violated assumption.");
291            }
292            // Everything else — including StopTest, so an out-of-data case is
293            // reported as an overrun instead of returning normally with a
294            // half-applied rule — unwinds through the caller.
295            Err(e) => resume_unwind(e),
296        };
297    }
298}