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    /// Lay the installed content out on a `size` display and read back every
161    /// semantics node with the geometry it was placed and drawn at.
162    ///
163    /// This is the headless answer to "how big is that control, and where" —
164    /// see [`crate::placed_semantics`] for what the two boxes on a node mean
165    /// and why every walk behind this reads the retained tree.
166    ///
167    /// The runtime handle is installed for the duration and taken back off
168    /// afterwards: a subcomposing widget cannot be measured without one, and
169    /// leaving it on outlives the borrow the applier hands out.
170    pub fn placed_semantics(
171        &mut self,
172        size: cranpose_ui::Size,
173    ) -> Result<Option<crate::placed_semantics::PlacedSemanticsNode>, NodeError> {
174        self.pump_until_idle()?;
175        let Some(root) = self.composition.root() else {
176            return Ok(None);
177        };
178        let handle = self.composition.runtime_handle();
179        let app_context = StdRc::clone(&self.app_context);
180        app_context.enter(|| {
181            let mut applier = self.composition.applier_mut();
182            applier.set_runtime_handle(handle);
183            let placed =
184                crate::placed_semantics::placed_semantics_from_applier(&mut applier, root, size);
185            applier.clear_runtime_handle();
186            placed
187        })
188    }
189
190    fn render(&mut self) -> Result<(), NodeError> {
191        let app_context = StdRc::clone(&self.app_context);
192        app_context.enter(|| {
193            let key = self.root_key();
194            if let Some(content) = self.content.as_mut() {
195                self.composition.render(key, &mut **content)?;
196                self.drain_root_render_requests()?;
197                // After composition runs, request render invalidation
198                // so that tests can detect when content has changed
199                request_render_invalidation();
200            }
201            Ok(())
202        })
203    }
204
205    fn drain_root_render_requests(&mut self) -> Result<(), NodeError> {
206        let key = self.root_key();
207        for _ in 0..ROOT_RENDER_REPLAY_LIMIT {
208            if !self.composition.take_root_render_request() {
209                return Ok(());
210            }
211            let content = self
212                .content
213                .as_mut()
214                .ok_or(NodeError::RecompositionLimitExceeded {
215                    operation: "root render replay",
216                    limit: ROOT_RENDER_REPLAY_LIMIT,
217                })?;
218            self.composition.render(key, &mut **content)?;
219            request_render_invalidation();
220        }
221
222        Err(NodeError::RecompositionLimitExceeded {
223            operation: "root render replay",
224            limit: ROOT_RENDER_REPLAY_LIMIT,
225        })
226    }
227}
228
229impl Default for ComposeTestRule {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235/// Convenience helper for tests that only need temporary access to a
236/// `ComposeTestRule`.
237pub fn run_test_composition<R>(f: impl FnOnce(&mut ComposeTestRule) -> R) -> R {
238    let mut rule = ComposeTestRule::new();
239    f(&mut rule)
240}
241
242#[cfg(test)]
243#[path = "tests/testing_tests.rs"]
244mod tests;
245
246#[cfg(test)]
247#[path = "tests/recomposition_tests.rs"]
248mod recomposition_tests;