Skip to main content

cranpose_testing/
testing.rs

1use cranpose_core::{
2    location_key, ApplierGuard, Composition, Key, MemoryApplier, NodeError, NodeId, RuntimeHandle,
3    ROOT_RENDER_REPLAY_LIMIT,
4};
5use cranpose_ui::{request_render_invalidation, reset_render_state_for_tests, AppContext};
6
7#[cfg(test)]
8use cranpose_core::{
9    pop_parent, push_parent, with_current_composer, with_node_mut, MutableState, Node,
10};
11#[cfg(test)]
12use std::cell::Cell;
13#[cfg(test)]
14use std::rc::Rc;
15use std::rc::Rc as StdRc;
16
17/// Headless harness for exercising compositions in tests.
18///
19/// `ComposeTestRule` mirrors the ergonomics of the Jetpack Compose testing APIs
20/// while remaining lightweight and allocation-friendly for unit tests. It owns
21/// an in-memory applier and exposes helpers for driving recomposition and
22/// draining frame callbacks without requiring a windowing backend.
23pub struct ComposeTestRule {
24    _scope: cranpose_ui::AppContextScope,
25    app_context: StdRc<AppContext>,
26    composition: Composition<MemoryApplier>,
27    content: Option<Box<dyn FnMut()>>, // Stored user content for reuse across recompositions.
28    initial_root_key: Key,
29}
30
31impl ComposeTestRule {
32    /// Create a new test rule backed by the default in-memory applier.
33    pub fn new() -> Self {
34        let app_context = AppContext::new();
35        app_context.enter(reset_render_state_for_tests);
36        let scope = app_context.enter_scope();
37        Self {
38            _scope: scope,
39            app_context,
40            composition: Composition::new(MemoryApplier::new()),
41            content: None,
42            initial_root_key: location_key(file!(), line!(), column!()),
43        }
44    }
45
46    fn root_key(&self) -> Key {
47        self.composition.root_key().unwrap_or(self.initial_root_key)
48    }
49
50    /// Install the provided content into the composition and perform an
51    /// initial render.
52    pub fn set_content(&mut self, content: impl FnMut() + 'static) -> Result<(), NodeError> {
53        self.content = Some(Box::new(content));
54        self.render()
55    }
56
57    /// Force a recomposition using the currently installed content.
58    pub fn recomposition(&mut self) -> Result<(), NodeError> {
59        self.render()
60    }
61
62    /// Drain scheduled frame callbacks at the supplied timestamp and process
63    /// any resulting work until the composition becomes idle.
64    pub fn advance_frame(&mut self, frame_time_nanos: u64) -> Result<(), NodeError> {
65        let app_context = StdRc::clone(&self.app_context);
66        app_context.enter(|| {
67            let handle = self.composition.runtime_handle();
68            handle.drain_frame_callbacks(frame_time_nanos);
69            self.pump_until_idle_in_context()
70        })
71    }
72
73    /// Drive the composition until there are no pending renders, invalidated
74    /// scopes, or enqueued node mutations remaining.
75    pub fn pump_until_idle(&mut self) -> Result<(), NodeError> {
76        let app_context = StdRc::clone(&self.app_context);
77        app_context.enter(|| self.pump_until_idle_in_context())
78    }
79
80    fn pump_until_idle_in_context(&mut self) -> Result<(), NodeError> {
81        let mut i = 0;
82        loop {
83            let mut progressed = false;
84            i += 1;
85            if i > ROOT_RENDER_REPLAY_LIMIT {
86                return Err(NodeError::RecompositionLimitExceeded {
87                    operation: "pump_until_idle",
88                    limit: ROOT_RENDER_REPLAY_LIMIT,
89                });
90            }
91
92            if self.composition.should_render() {
93                self.render()?;
94                progressed = true;
95            }
96
97            let handle = self.composition.runtime_handle();
98            if handle.has_updates() {
99                self.composition.flush_pending_node_updates()?;
100                progressed = true;
101            }
102
103            if handle.has_invalid_scopes() {
104                let changed = self.composition.process_invalid_scopes()?;
105                if changed {
106                    // Request render invalidation so tests can detect composition changes
107                    request_render_invalidation();
108                }
109                if self.composition.take_root_render_request() {
110                    self.render()?;
111                }
112                progressed = true;
113            }
114
115            if !progressed {
116                break;
117            }
118        }
119        Ok(())
120    }
121
122    /// Access the runtime driving this rule. Useful for constructing shared
123    /// state objects within the composition.
124    pub fn runtime_handle(&self) -> RuntimeHandle {
125        self.composition.runtime_handle()
126    }
127
128    /// Gain mutable access to the underlying in-memory applier for assertions
129    /// about the produced node tree.
130    pub fn applier_mut(&mut self) -> ApplierGuard<'_, MemoryApplier> {
131        self.composition.applier_mut()
132    }
133
134    /// Dump the current node tree as text for debugging
135    pub fn dump_tree(&mut self) -> String {
136        let root = self.composition.root();
137        let applier = self.composition.applier_mut();
138        applier.dump_tree(root)
139    }
140
141    /// Returns whether user content has been installed in this rule.
142    pub fn has_content(&self) -> bool {
143        self.content.is_some()
144    }
145
146    /// Returns the id of the root node produced by the current composition.
147    pub fn root_id(&self) -> Option<NodeId> {
148        self.composition.root()
149    }
150
151    pub fn with_app_context<R>(&self, block: impl FnOnce() -> R) -> R {
152        self.app_context.enter(block)
153    }
154
155    /// Gain mutable access to the raw composition for advanced scenarios.
156    pub fn composition(&mut self) -> &mut Composition<MemoryApplier> {
157        &mut self.composition
158    }
159
160    fn render(&mut self) -> Result<(), NodeError> {
161        let app_context = StdRc::clone(&self.app_context);
162        app_context.enter(|| {
163            let key = self.root_key();
164            if let Some(content) = self.content.as_mut() {
165                self.composition.render(key, &mut **content)?;
166                self.drain_root_render_requests()?;
167                // After composition runs, request render invalidation
168                // so that tests can detect when content has changed
169                request_render_invalidation();
170            }
171            Ok(())
172        })
173    }
174
175    fn drain_root_render_requests(&mut self) -> Result<(), NodeError> {
176        let key = self.root_key();
177        for _ in 0..ROOT_RENDER_REPLAY_LIMIT {
178            if !self.composition.take_root_render_request() {
179                return Ok(());
180            }
181            let content = self
182                .content
183                .as_mut()
184                .ok_or(NodeError::RecompositionLimitExceeded {
185                    operation: "root render replay",
186                    limit: ROOT_RENDER_REPLAY_LIMIT,
187                })?;
188            self.composition.render(key, &mut **content)?;
189            request_render_invalidation();
190        }
191
192        Err(NodeError::RecompositionLimitExceeded {
193            operation: "root render replay",
194            limit: ROOT_RENDER_REPLAY_LIMIT,
195        })
196    }
197}
198
199impl Default for ComposeTestRule {
200    fn default() -> Self {
201        Self::new()
202    }
203}
204
205/// Convenience helper for tests that only need temporary access to a
206/// `ComposeTestRule`.
207pub fn run_test_composition<R>(f: impl FnOnce(&mut ComposeTestRule) -> R) -> R {
208    let mut rule = ComposeTestRule::new();
209    f(&mut rule)
210}
211
212#[cfg(test)]
213#[path = "tests/testing_tests.rs"]
214mod tests;
215
216#[cfg(test)]
217#[path = "tests/recomposition_tests.rs"]
218mod recomposition_tests;