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