Skip to main content

rust_elm/
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    tokio::runtime::Builder::new_current_thread()
154        .enable_all()
155        .build()
156        .expect("test tokio runtime")
157        .block_on(fut)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::effect::Effect;
164    use crate::panic_on_state_clone;
165    use crate::test_support::allow_state_clones;
166
167    panic_on_state_clone! {
168        #[derive(Debug, PartialEq, Eq, Default)]
169        struct Counter {
170            n: i32,
171        }
172    }
173
174    fn update(s: &mut Counter, msg: i32) -> Cmd<i32> {
175        match msg {
176            0 => Cmd::single(Effect::task(1, || Box::pin(async { Ok(10) }))),
177            n => {
178                s.n = n;
179                Cmd::none()
180            }
181        }
182    }
183
184    #[test]
185    fn exhaustive_requires_effect_actions_be_received() {
186        let mut store = ExhaustiveTestStore::new(Counter::default(), update);
187        store.send(0);
188        assert_eq!(store.pending_actions.len(), 1);
189        store.receive(10);
190        store.finish();
191        assert_eq!(store.state.n, 10);
192    }
193
194    #[test]
195    fn send_with_validates_expected_state() {
196        let mut store = ExhaustiveTestStore::new(Counter::default(), update);
197        allow_state_clones(1, || {
198            store.send_with(5, |s| {
199                s.n = 5;
200            });
201        });
202        store.finish();
203    }
204
205    #[test]
206    #[should_panic(expected = "unconsumed effect actions")]
207    fn finish_fails_when_actions_remain() {
208        let mut store = ExhaustiveTestStore::new(Counter::default(), update);
209        store.send(0);
210        store.finish();
211    }
212
213    #[test]
214    fn non_exhaustive_allows_leftover_actions() {
215        let mut store = ExhaustiveTestStore::new(Counter::default(), update).with_exhaustivity(false);
216        store.send(0);
217        store.finish();
218    }
219}