Skip to main content

rust_elm/testing/
test_support.rs

1//! Test helpers for asserting store/reducer paths do not clone feature state.
2//!
3//! Wrap reducer state types with [`panic_on_state_clone!`] and allow only explicit
4//! clones via [`allow_state_clones`].
5
6use std::cell::Cell;
7
8thread_local! {
9    static CLONE_BUDGET: Cell<usize> = const { Cell::new(0) };
10}
11
12/// Panics unless the current thread has remaining clone budget from [`allow_state_clones`].
13pub fn on_state_clone(type_name: &str) {
14    CLONE_BUDGET.with(|budget| {
15        let remaining = budget.get();
16        assert!(
17            remaining > 0,
18            "unexpected clone of {type_name} (clone budget exhausted)"
19        );
20        budget.set(remaining - 1);
21    });
22}
23
24/// Temporarily allow `count` feature-state clones on this thread.
25pub fn allow_state_clones<T>(count: usize, f: impl FnOnce() -> T) -> T {
26    CLONE_BUDGET.with(|budget| {
27        let prev = budget.get();
28        budget.set(prev + count);
29        let out = f();
30        budget.set(prev);
31        out
32    })
33}
34
35#[cfg(feature = "runtime")]
36mod store_helpers {
37    use super::allow_state_clones;
38    use crate::optics::Casepath;
39    use crate::store::{ScopedStateSubscriber, StateSubscriber};
40
41    /// [`Store::state`] clones feature state once — use in tests that intentionally read a snapshot.
42    pub fn store_state<S, M>(store: &crate::Store<S, M>) -> S
43    where
44        S: Send + Sync + Clone + 'static,
45        M: Send + 'static,
46    {
47        allow_state_clones(1, || store.state())
48    }
49
50    /// [`Store::subscribe_state`] clones once when the subscriber is created.
51    pub fn subscribe_state<S, M>(store: &crate::Store<S, M>) -> crate::StateSubscriber<S>
52    where
53        S: Clone + PartialEq + Send + Sync + 'static,
54        M: Send + 'static,
55    {
56        allow_state_clones(1, || store.subscribe_state())
57    }
58
59    /// [`ScopedStore::child_state`] clones parent state once via [`Store::state`].
60    pub fn scoped_child_state<S, M, CS, CM, AK, SK>(
61        scoped: &crate::ScopedStore<S, M, CS, CM, AK, SK>,
62    ) -> Option<CS>
63    where
64        S: Send + Sync + Clone,
65        M: Send,
66        CS: Clone + PartialEq + Send + Sync,
67        CM: Clone + Send,
68        AK: Casepath<M, CM> + Clone + Send + Sync + 'static,
69        SK: crate::keypath::RefKpTrait<S, CS> + Clone,
70    {
71        allow_state_clones(1, || scoped.child_state())
72    }
73
74    /// [`ScopedStore::subscribe_state`] clones parent state once when the subscriber is created.
75    pub fn scoped_subscribe_state<S, M, CS, CM, AK, SK>(
76        scoped: &crate::ScopedStore<S, M, CS, CM, AK, SK>,
77    ) -> ScopedStateSubscriber<S, CS, SK>
78    where
79        S: Send + Sync + Clone + PartialEq,
80        M: Send,
81        CS: Clone + PartialEq + Send + Sync,
82        CM: Clone + Send,
83        AK: Casepath<M, CM> + Clone + Send + Sync + 'static,
84        SK: crate::keypath::RefKpTrait<S, CS> + Clone,
85    {
86        allow_state_clones(1, || scoped.subscribe_state())
87    }
88
89    /// One [`StateSubscriber::next`] / [`StateSubscriber::wait_next`] may clone feature state once.
90    pub fn subscriber_wait_next<S>(
91        sub: &mut StateSubscriber<S>,
92        timeout: std::time::Duration,
93    ) -> Option<std::sync::Arc<S>>
94    where
95        S: PartialEq + Clone,
96    {
97        allow_state_clones(1, || sub.wait_next(timeout))
98    }
99
100    /// One [`ScopedStateSubscriber::next`] may clone parent feature state once.
101    pub fn scoped_subscriber_next<S, CS, SK>(
102        sub: &mut ScopedStateSubscriber<S, CS, SK>,
103    ) -> Option<CS>
104    where
105        S: PartialEq + Clone,
106        CS: Clone + PartialEq,
107        SK: crate::keypath::RefKpTrait<S, CS> + Clone,
108    {
109        allow_state_clones(1, || sub.next())
110    }
111}
112
113#[cfg(feature = "runtime")]
114pub use store_helpers::{
115    scoped_child_state, scoped_subscribe_state, scoped_subscriber_next, store_state,
116    subscribe_state, subscriber_wait_next,
117};
118
119/// [`ReplayHarness::snapshot`] clones feature state once.
120pub fn replay_snapshot<S, M>(harness: &crate::ReplayHarness<S, M>) -> S
121where
122    S: Clone,
123    M: Clone,
124{
125    allow_state_clones(1, || harness.snapshot())
126}
127
128/// [`Shared::with_mut`] clones feature state once to detect changes.
129pub fn shared_with_mut<T, R>(shared: &crate::Shared<T>, f: impl FnOnce(&mut T) -> R) -> R
130where
131    T: Clone + PartialEq,
132{
133    allow_state_clones(1, || shared.with_mut(f))
134}
135
136/// [`Shared::get`] clones feature state once.
137pub fn shared_get<S>(shared: &crate::Shared<S>) -> S
138where
139    S: Clone,
140{
141    allow_state_clones(1, || shared.get())
142}
143
144/// Define a reducer/store state struct whose [`Clone`] panics unless budget is available.
145#[macro_export]
146macro_rules! panic_on_state_clone {
147    (
148        $(#[$meta:meta])*
149        $vis:vis struct $name:ident {
150            $($field:ident: $ty:ty),* $(,)?
151        }
152    ) => {
153        $(#[$meta])*
154        $vis struct $name {
155            $($field: $ty),*
156        }
157
158        impl Clone for $name {
159            fn clone(&self) -> Self {
160                $crate::test_support::on_state_clone(concat!(module_path!(), "::", stringify!($name)));
161                Self {
162                    $($field: self.$field.clone()),*
163                }
164            }
165        }
166    };
167}
168
169/// Start a [`Runtime`](crate::Runtime) in tests/examples; panics only if bootstrap fails.
170#[cfg(feature = "runtime")]
171pub fn start_runtime<S, M>(
172    program: crate::Program<S, M>,
173    env: crate::Environment,
174    config: crate::RuntimeConfig,
175) -> crate::Runtime<S, M>
176where
177    S: Send + Sync + 'static,
178    M: Send + 'static,
179{
180    match crate::Runtime::from_program(program, env, config) {
181        Ok(runtime) => runtime,
182        Err(err) => panic!("runtime bootstrap failed: {err}"),
183    }
184}
185
186/// Start a [`Runtime`](crate::Runtime) from a [`ReducerProgram`](crate::ReducerProgram).
187#[cfg(feature = "runtime")]
188pub fn start_reducer_runtime<R>(program: crate::ReducerProgram<R>, env: crate::Environment, config: crate::RuntimeConfig) -> crate::Runtime<R::State, R::Action>
189where
190    R: crate::Reducer + Send + Sync + 'static,
191    R::State: Send + Sync + Clone + 'static,
192    R::Action: Send + 'static,
193{
194    match crate::Runtime::from_reducer_program(program, env, config) {
195        Ok(runtime) => runtime,
196        Err(err) => panic!("runtime bootstrap failed: {err}"),
197    }
198}
199
200#[cfg(feature = "runtime")]
201pub fn start_rw_reducer_runtime<R>(program: crate::ReducerProgram<R>, env: crate::Environment, config: crate::RuntimeConfig) -> crate::RwRuntime<R::State, R::Action>
202where
203    R: crate::Reducer + Send + Sync + 'static,
204    R::State: Send + Sync + 'static,
205    R::Action: Send + 'static,
206{
207    match crate::RwRuntime::from_reducer_program(program, env, config) {
208        Ok(runtime) => runtime,
209        Err(err) => panic!("runtime bootstrap failed: {err}"),
210    }
211}
212
213#[cfg(all(feature = "runtime", feature = "arc-swap"))]
214pub fn start_swap_reducer_runtime<R>(program: crate::ReducerProgram<R>, env: crate::Environment, config: crate::RuntimeConfig) -> crate::SwapRuntime<R::State, R::Action>
215where
216    R: crate::Reducer + Send + Sync + 'static,
217    R::State: Send + Sync + Clone + 'static,
218    R::Action: Send + 'static,
219{
220    match crate::SwapRuntime::from_reducer_program(program, env, config) {
221        Ok(runtime) => runtime,
222        Err(err) => panic!("runtime bootstrap failed: {err}"),
223    }
224}
225
226#[cfg(feature = "runtime")]
227pub fn start_tea_reducer_runtime<R>(program: crate::ReducerProgram<R>, env: crate::Environment, config: crate::RuntimeConfig) -> crate::TeaRuntime<R::State, R::Action>
228where
229    R: crate::Reducer + Send + Sync + 'static,
230    R::State: Send + Sync + Clone + 'static,
231    R::Action: Send + 'static,
232{
233    match crate::TeaRuntime::from_reducer_program(program, env, config) {
234        Ok(runtime) => runtime,
235        Err(err) => panic!("runtime bootstrap failed: {err}"),
236    }
237}