Skip to main content

rust_elm/testing/
test_store.rs

1use std::collections::VecDeque;
2use std::fmt;
3use std::time::{Duration, Instant};
4
5use crate::cmd::Cmd;
6use crate::effect::{run_leaf, Effect};
7use crate::env::Environment;
8
9/// Exhaustive test store — every effect-produced action must be explicitly consumed (UDF parity).
10pub struct ExhaustiveTestStore<S, M> {
11    pub state: S,
12    update: fn(&mut S, M) -> Cmd<M>,
13    env: Environment,
14    pending_actions: VecDeque<M>,
15    pending_effects: Vec<Effect<M>>,
16    exhaustive: bool,
17}
18
19impl<S, M> ExhaustiveTestStore<S, M>
20where
21    S: fmt::Debug + PartialEq + Clone,
22    M: PartialEq + fmt::Debug + Send + 'static,
23{
24    pub fn new(state: S, update: fn(&mut S, M) -> Cmd<M>) -> Self {
25        Self {
26            state,
27            update,
28            env: Environment::test(),
29            pending_actions: VecDeque::new(),
30            pending_effects: Vec::new(),
31            exhaustive: true,
32        }
33    }
34
35    pub fn with_exhaustivity(mut self, on: bool) -> Self {
36        self.exhaustive = on;
37        self
38    }
39
40    pub fn send(&mut self, action: M) {
41        self.send_expect(action, None);
42    }
43
44    /// Run a command without dispatching an action (e.g. the `Cmd` from `init`).
45    pub fn boot(&mut self, cmd: Cmd<M>) {
46        if self.exhaustive {
47            self.assert_idle();
48        }
49        self.enqueue_effects(cmd.into_effects());
50    }
51
52    pub fn send_with(&mut self, action: M, expect: impl FnOnce(&mut S)) {
53        let mut expected = self.state.clone();
54        expect(&mut expected);
55        self.send_expect(action, Some(expected));
56    }
57
58    fn send_expect(&mut self, action: M, expected: Option<S>) {
59        if self.exhaustive {
60            self.assert_idle();
61        }
62        let cmd = (self.update)(&mut self.state, action);
63        if let Some(expected) = expected {
64            assert_state_eq(&expected, &self.state);
65        }
66        self.enqueue_effects(cmd.into_effects());
67    }
68
69    pub fn receive(&mut self, expected: M) {
70        let actual = self
71            .pending_actions
72            .pop_front()
73            .unwrap_or_else(|| panic!("expected action {expected:?}, but effect queue was empty"));
74        assert_eq!(actual, expected, "unexpected effect-produced action");
75        let cmd = (self.update)(&mut self.state, actual);
76        self.enqueue_effects(cmd.into_effects());
77    }
78
79    pub fn finish(&self) {
80        if self.exhaustive {
81            self.assert_idle();
82        }
83    }
84
85    fn assert_idle(&self) {
86        assert!(
87            self.pending_actions.is_empty(),
88            "unconsumed effect actions: {:?}",
89            self.pending_actions
90        );
91        assert!(
92            self.pending_effects.is_empty(),
93            "uninterpreted effects: {:?}",
94            self.pending_effects
95        );
96    }
97
98    fn enqueue_effects(&mut self, effects: Vec<Effect<M>>) {
99        for effect in effects {
100            self.pending_effects.push(effect);
101        }
102        while let Some(effect) = self.pending_effects.pop() {
103            self.run_effect_leaf(effect);
104        }
105    }
106
107    fn run_effect_leaf(&mut self, effect: Effect<M>) {
108        match effect {
109            Effect::None => {}
110            Effect::Batch(items) | Effect::Race(items) => {
111                for item in items {
112                    self.run_effect_leaf(item);
113                }
114            }
115            Effect::Sequence(items) => {
116                for item in items {
117                    self.run_effect_leaf(item);
118                }
119            }
120            leaf => {
121                let fut = run_leaf(leaf, &self.env);
122                let msg = tokio_test_block_on(fut);
123                match msg {
124                    Ok(action) => self.pending_actions.push_back(action),
125                    Err(err) => panic!("effect failed in test store: {err}"),
126                }
127            }
128        }
129    }
130
131    pub fn receive_timeout(&mut self, expected: M, timeout: Duration) -> Result<(), TestStoreError> {
132        let deadline = Instant::now() + timeout;
133        while Instant::now() < deadline {
134            if !self.pending_actions.is_empty() {
135                self.receive(expected);
136                return Ok(());
137            }
138            std::thread::sleep(Duration::from_millis(1));
139        }
140        Err(TestStoreError::ReceiveTimeout)
141    }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum TestStoreError {
146    ReceiveTimeout,
147}
148
149fn assert_state_eq<S: fmt::Debug + PartialEq>(expected: &S, actual: &S) {
150    assert_eq!(
151        expected, actual,
152        "state mismatch after send\n  expected: {expected:?}\n  actual:   {actual:?}"
153    );
154}
155
156fn tokio_test_block_on<M: Send + 'static>(
157    fut: std::pin::Pin<
158        Box<dyn std::future::Future<Output = Result<M, crate::EffectError>> + Send>,
159    >,
160) -> Result<M, crate::EffectError> {
161    match tokio::runtime::Builder::new_current_thread()
162        .enable_all()
163        .build()
164    {
165        Ok(rt) => rt.block_on(fut),
166        Err(err) => Err(crate::EffectError::Other(format!(
167            "failed to create test tokio runtime: {err}"
168        ))),
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::effect::Effect;
176    use crate::panic_on_state_clone;
177    use crate::test_support::allow_state_clones;
178
179    panic_on_state_clone! {
180        #[derive(Debug, PartialEq, Eq, Default)]
181        struct Counter {
182            n: i32,
183        }
184    }
185
186    fn update(s: &mut Counter, msg: i32) -> Cmd<i32> {
187        match msg {
188            0 => Cmd::single(Effect::task(1, || Box::pin(async { Ok(10) }))),
189            n => {
190                s.n = n;
191                Cmd::none()
192            }
193        }
194    }
195
196    #[test]
197    fn exhaustive_requires_effect_actions_be_received() {
198        let mut store = ExhaustiveTestStore::new(Counter::default(), update);
199        store.send(0);
200        assert_eq!(store.pending_actions.len(), 1);
201        store.receive(10);
202        store.finish();
203        assert_eq!(store.state.n, 10);
204    }
205
206    #[test]
207    fn send_with_validates_expected_state() {
208        let mut store = ExhaustiveTestStore::new(Counter::default(), update);
209        allow_state_clones(1, || {
210            store.send_with(5, |s| {
211                s.n = 5;
212            });
213        });
214        store.finish();
215    }
216
217    #[test]
218    #[should_panic(expected = "unconsumed effect actions")]
219    fn finish_fails_when_actions_remain() {
220        let mut store = ExhaustiveTestStore::new(Counter::default(), update);
221        store.send(0);
222        store.finish();
223    }
224
225    #[test]
226    fn non_exhaustive_allows_leftover_actions() {
227        let mut store = ExhaustiveTestStore::new(Counter::default(), update).with_exhaustivity(false);
228        store.send(0);
229        store.finish();
230    }
231}