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