Skip to main content

ferroui_reactiveui_testing/
rx_test.rs

1//! `RxTest` (`ReactiveUI.Testing/RxTest.cs`).
2
3use ferroui_reactive::linq::TimeoutException;
4use ferroui_reactiveui::PanicException;
5use ferroui_splat::AppBuilder;
6use ferroui_system::threading::tasks::{run, Task, TaskCompletionSource};
7use ferroui_system::ObservableError;
8use std::cell::{Cell, RefCell};
9use std::collections::VecDeque;
10use std::future::{poll_fn, Future};
11use std::pin::Pin;
12use std::rc::Rc;
13use std::task::Poll;
14use std::time::Duration;
15
16/// How long a test waits for the gate, and runs, by default (`DefaultMaxWaitMs`).
17const DEFAULT_MAX_WAIT_MS: u64 = 60_000;
18
19/// The gate that lets one test of the application builder run at a time (`TestGate`, a
20/// `SemaphoreSlim` of one): whether a test holds it, and the tests waiting for it in order.
21#[derive(Default)]
22struct TestGate {
23    held: Cell<bool>,
24    waiters: RefCell<VecDeque<TaskCompletionSource<()>>>,
25}
26
27ferroui_system::ferro_thread_local! {
28    /// The gate of the thread.
29    static TEST_GATE: Rc<TestGate> = Rc::new(TestGate::default());
30}
31
32impl TestGate {
33    fn current() -> Rc<Self> {
34        TEST_GATE.with(Rc::clone)
35    }
36
37    /// Takes the gate, waiting at most `max_wait` for the test that holds it; false when the time
38    /// ran out (`TestGate.WaitAsync(maxWaitMs)`).
39    async fn wait(self: Rc<Self>, max_wait: Duration) -> bool {
40        if !self.held.get() {
41            self.held.set(true);
42            return true;
43        }
44        let turn = TaskCompletionSource::<()>::new();
45        let granted = turn.task();
46        self.waiters.borrow_mut().push_back(turn);
47        match first_of(granted.clone(), delay(max_wait)).await {
48            First::Left(_) => true,
49            First::Right(_) => {
50                self.waiters.borrow_mut().retain(|waiter| waiter.task().id() != granted.id());
51                granted.is_completed()
52            }
53        }
54    }
55
56    /// Gives the gate to the next waiting test, or opens it (`TestGate.Release()`).
57    fn release(&self) {
58        let next = self.waiters.borrow_mut().pop_front();
59        match next {
60            Some(next) => next.set_result(()),
61            None => self.held.set(false),
62        }
63    }
64}
65
66/// Which of two tasks ended first.
67enum First<A, B> {
68    Left(A),
69    Right(B),
70}
71
72/// The first of two tasks to end, `left` when both have (`Task.WhenAny`).
73async fn first_of<A: Clone + 'static, B: Clone + 'static>(
74    mut left: Task<A>,
75    mut right: Task<B>,
76) -> First<Result<A, ObservableError>, Result<B, ObservableError>> {
77    poll_fn(move |context| {
78        if let Poll::Ready(outcome) = Pin::new(&mut left).poll(context) {
79            return Poll::Ready(First::Left(outcome));
80        }
81        if let Poll::Ready(outcome) = Pin::new(&mut right).poll(context) {
82            return Poll::Ready(First::Right(outcome));
83        }
84        Poll::Pending
85    })
86    .await
87}
88
89/// A task that ends after `duration` (`Task.Delay`).
90///
91/// Deviation (DEVIATIONS.md, ReactiveUI): `ferroui-system` has no timer of tasks; the time passes on
92/// a worker thread ([`run`]), and the task ends on the current thread.
93fn delay(duration: Duration) -> Task<()> {
94    run(move || {
95        std::thread::sleep(duration);
96        Ok(())
97    })
98}
99
100/// Runs a test of the application builder of Splat from a clean builder state, one test at a time
101/// and within a time limit (`RxTest`).
102///
103/// Deviation (DEVIATIONS.md, ReactiveUI): the state of the application builder is per thread, as
104/// the rest of the state of ReactiveUI and Splat is, so the gate lets one test of the thread run
105/// at a time, not one of the process.
106pub struct RxTest;
107
108impl RxTest {
109    /// [`app_builder_test_async_with`](Self::app_builder_test_async_with) with the default time
110    /// limit of 60 seconds (`AppBuilderTestAsync(Func<Task>)`).
111    pub fn app_builder_test_async(test_body: impl FnOnce() -> Task<()> + 'static) -> Task<()> {
112        Self::app_builder_test_async_with(test_body, DEFAULT_MAX_WAIT_MS)
113    }
114
115    /// Runs `test_body` once no other test of the application builder runs, with the state of the
116    /// application builder reset before and after it, and gives a task that ends as the test does
117    /// (`AppBuilderTestAsync(Func<Task>, int)`).
118    ///
119    /// The task ends with a [`TimeoutException`] when the gate is not free within `max_wait_ms`
120    /// milliseconds, or when the test runs longer than that; with the error of the test when it
121    /// fails, and with the error of a panic ([`PanicException`]) when `test_body` panics, as an
122    /// exception thrown in an `async` method faults its task.
123    ///
124    /// Not ported: the `ArgumentException` of a null test body - argument validation.
125    pub fn app_builder_test_async_with(test_body: impl FnOnce() -> Task<()> + 'static, max_wait_ms: u64) -> Task<()> {
126        let max_wait = Duration::from_millis(max_wait_ms);
127        Task::spawn_local(async move {
128            // Takes the gate, with a time limit so that a test that never ends cannot block the
129            // others forever.
130            let gate = TestGate::current();
131            if !gate.clone().wait(max_wait).await {
132                return Err(Rc::new(TimeoutException) as ObservableError);
133            }
134
135            // Resets any previous builder state.
136            AppBuilder::reset_builder_state_for_tests();
137
138            // Runs the test with a time limit, so that it cannot hang forever, and passes on its
139            // error.
140            let test_task = match PanicException::catch(test_body) {
141                Ok(task) => task,
142                Err((error, _payload)) => Task::from_exception(error),
143            };
144            let outcome = match first_of(test_task, delay(max_wait)).await {
145                First::Left(outcome) => outcome,
146                First::Right(_) => Err(Rc::new(TimeoutException) as ObservableError),
147            };
148
149            // The final reset after the test.
150            AppBuilder::reset_builder_state_for_tests();
151            gate.release();
152            outcome
153        })
154    }
155}