Skip to main content

cranpose_testing/
testing.rs

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