dioxus_dnd/test.rs
1//! Headless test driver - drag-and-drop in CI, no browser.
2//!
3//! The drag state machine is plain Rust over signals, so a whole pointer
4//! interaction can run inside a `VirtualDom`: pick up, hover, drop, assert.
5//! The one thing a headless run lacks is layout, so *you place the zone
6//! rects* - which makes tests deterministic instead of flaky.
7//!
8//! Mount a [`DragSimProbe`] inside the provider under test, grab the
9//! [`DragSim`] it captured, and drive:
10//!
11//! ```text
12//! fn test_app() -> Element {
13//! rsx! {
14//! DndProvider::<Card> {
15//! DragSimProbe::<Card> {}
16//! ShelfApp {} // the component you're testing
17//! }
18//! }
19//! }
20//!
21//! let mut dom = VirtualDom::new(test_app);
22//! dom.rebuild_in_place();
23//! let mut sim = drag_sim::<Card>();
24//!
25//! sim.place(&dom, SHELF, Rect::new(0.0, 100.0, 200.0, 80.0));
26//! sim.pick_up(&dom, card.clone());
27//! sim.move_to(&dom, Point::new(100.0, 140.0));
28//! assert_eq!(sim.over(&dom), Some(SHELF));
29//! rerender(&mut dom);
30//! assert!(dioxus_ssr::render(&dom).contains("data-over"));
31//! assert_eq!(sim.release(&dom), Some(SHELF)); // your on_drop just ran
32//! ```
33//!
34//! Or as one line for the common arc: [`simulate_drag`].
35//!
36//! Drops go through the *production* delivery path - acceptance filters,
37//! `DropOutcome` construction, closest-edge enrichment, settle routing -
38//! shared with `Draggable` itself, not a reimplementation. Releases mirror
39//! the pointer gesture: an exact hit wins; otherwise the drop snaps to the
40//! closest acceptable zone whose edge is within 48px (the touch
41//! forgiveness), else the drag cancels. Not simulated: pointer capture,
42//! auto-scroll, and the re-measure that precedes the real snap (headless
43//! rects are wherever you placed them).
44
45use std::any::{Any, TypeId};
46use std::cell::RefCell;
47use std::collections::HashMap;
48
49use dioxus::prelude::*;
50
51use crate::core::components::deliver_drop;
52use crate::core::hooks::SettleFlag;
53use crate::core::{
54 use_dnd, use_zone_registry, DndContext, DragMode, DropEffect, Point, Rect, ZoneId, ZoneRegistry,
55};
56
57thread_local! {
58 /// Handles captured by [`DragSimProbe`], keyed by payload type. One
59 /// slot per type per thread: the most recently mounted probe wins,
60 /// which is exactly right for one `VirtualDom` per test.
61 static SIMS: RefCell<HashMap<TypeId, Box<dyn Any>>> = RefCell::new(HashMap::new());
62}
63
64/// Captures a [`DragSim`] for the enclosing provider. Mount one inside the
65/// `DndProvider<T>` of your *test* app (it renders nothing), then retrieve
66/// the handle with [`drag_sim`] after `rebuild_in_place`.
67#[component]
68pub fn DragSimProbe<T: Clone + PartialEq + 'static>(
69 /// Internal marker; never set this.
70 #[props(default)]
71 phantom: std::marker::PhantomData<T>,
72) -> Element {
73 let _ = phantom;
74 let sim = DragSim {
75 dnd: use_dnd::<T>(),
76 registry: use_zone_registry::<T>(),
77 settle: try_use_context::<SettleFlag<T>>(),
78 };
79 use_hook(move || {
80 SIMS.with_borrow_mut(|m| {
81 m.insert(TypeId::of::<T>(), Box::new(sim));
82 });
83 });
84 rsx! {}
85}
86
87/// The handle the most recent [`DragSimProbe<T>`] captured.
88///
89/// # Panics
90/// Panics when no probe for `T` has mounted - add `DragSimProbe::<T> {}`
91/// inside the provider and `rebuild_in_place` first.
92pub fn drag_sim<T: Clone + PartialEq + 'static>() -> DragSim<T> {
93 SIMS.with_borrow(|m| {
94 m.get(&TypeId::of::<T>())
95 .and_then(|b| b.downcast_ref::<DragSim<T>>())
96 .copied()
97 })
98 .expect("no DragSim captured: mount DragSimProbe::<T> inside the provider and rebuild first")
99}
100
101/// Headless driver for one provider's drag world. Every method takes the
102/// `VirtualDom` so the underlying signal operations run inside its runtime;
103/// call [`rerender`] between actions and markup assertions.
104pub struct DragSim<T: Clone + 'static> {
105 dnd: DndContext<T>,
106 registry: ZoneRegistry<T>,
107 settle: Option<SettleFlag<T>>,
108}
109
110impl<T: Clone + 'static> Copy for DragSim<T> {}
111impl<T: Clone + 'static> Clone for DragSim<T> {
112 fn clone(&self) -> Self {
113 *self
114 }
115}
116
117impl<T: Clone + PartialEq + 'static> DragSim<T> {
118 /// Give a zone its client rect - the headless stand-in for layout.
119 ///
120 /// # Panics
121 /// Panics when no zone with this id is registered.
122 pub fn place(&self, dom: &VirtualDom, zone: ZoneId, rect: Rect) {
123 dom.in_runtime(|| {
124 let record = self
125 .registry
126 .get(zone)
127 .unwrap_or_else(|| panic!("place: no zone {} registered", zone.0));
128 let mut slot = record.rect;
129 slot.set(Some(rect));
130 });
131 }
132
133 /// Begin a pointer drag carrying `payload`, from no particular zone.
134 pub fn pick_up(&mut self, dom: &VirtualDom, payload: T) {
135 self.pick_up_from(dom, payload, None);
136 }
137
138 /// Begin a pointer drag, reporting `from` as the source zone
139 /// (arrives in `DropOutcome::from`).
140 pub fn pick_up_from(&mut self, dom: &VirtualDom, payload: T, from: Option<ZoneId>) {
141 let mut dnd = self.dnd;
142 dom.in_runtime(|| {
143 dnd.start(
144 payload,
145 from,
146 Point::default(),
147 Point::default(),
148 DropEffect::Move,
149 DragMode::Pointer,
150 );
151 });
152 }
153
154 /// Move the pointer: updates the tracked position and enters/leaves
155 /// zones by hit-testing the placed rects - the same logic the pointer
156 /// gesture runs per `pointermove`.
157 pub fn move_to(&mut self, dom: &VirtualDom, point: Point) {
158 let mut dnd = self.dnd;
159 let registry = self.registry;
160 dom.in_runtime(|| {
161 dnd.update_pointer(point);
162 match registry.hit_test(point) {
163 Some(z) => dnd.enter(z),
164 None => {
165 if let Some(over) = dnd.over() {
166 dnd.leave(over);
167 }
168 }
169 }
170 });
171 }
172
173 /// Release at the current pointer position. Returns the zone that
174 /// received the drop, or `None` when the drag cancelled (no acceptable
175 /// zone under the pointer, and none with an edge within the 48px
176 /// snap).
177 pub fn release(&mut self, dom: &VirtualDom) -> Option<ZoneId> {
178 self.release_as(dom, DropEffect::Move)
179 }
180
181 /// [`Self::release`] with an explicit effect - simulate the Ctrl-held
182 /// copy drop with `DropEffect::Copy`.
183 pub fn release_as(&mut self, dom: &VirtualDom, effect: DropEffect) -> Option<ZoneId> {
184 let mut dnd = self.dnd;
185 let registry = self.registry;
186 let settle = self.settle;
187 dom.in_runtime(|| {
188 let point = dnd.pointer();
189 let target = registry.hit_test(point).or_else(|| {
190 dnd.payload()
191 .and_then(|p| registry.hit_test_closest(point, &p, 48.0))
192 });
193 let delivered = target
194 .filter(|t| deliver_drop(registry, &mut dnd, settle, *t, point, effect))
195 .is_some();
196 if !delivered {
197 dnd.cancel();
198 return None;
199 }
200 target
201 })
202 }
203
204 /// Abort the drag, as Escape or a pointer cancel would.
205 pub fn cancel(&mut self, dom: &VirtualDom) {
206 let mut dnd = self.dnd;
207 dom.in_runtime(|| dnd.cancel());
208 }
209
210 /// The zone currently hovered.
211 pub fn over(&self, dom: &VirtualDom) -> Option<ZoneId> {
212 dom.in_runtime(|| self.dnd.over())
213 }
214
215 /// Is a drag in flight?
216 pub fn dragging(&self, dom: &VirtualDom) -> bool {
217 dom.in_runtime(|| self.dnd.dragging())
218 }
219
220 /// The in-flight payload, if any.
221 pub fn payload(&self, dom: &VirtualDom) -> Option<T> {
222 dom.in_runtime(|| self.dnd.payload())
223 }
224
225 /// The latest screen-reader announcement.
226 pub fn announcement(&self, dom: &VirtualDom) -> String {
227 dom.in_runtime(|| self.dnd.announcement())
228 }
229}
230
231/// Flush pending reactivity so the tree reflects the simulated state -
232/// call between driver actions and markup assertions
233/// (`dioxus_ssr::render`).
234pub fn rerender(dom: &mut VirtualDom) {
235 dom.process_events();
236 dom.render_immediate(&mut dioxus::core::NoOpMutations);
237}
238
239/// One whole pointer drag: pick `payload` up (from `from`), glide through
240/// `path`, release at its last point, re-rendering between steps so zone
241/// reactions run just as they would live. Returns the receiving zone, or
242/// `None` when the drag cancelled. Needs a mounted [`DragSimProbe<T>`];
243/// an empty `path` releases at the pickup point.
244pub fn simulate_drag<T: Clone + PartialEq + 'static>(
245 dom: &mut VirtualDom,
246 payload: T,
247 from: Option<ZoneId>,
248 path: &[Point],
249) -> Option<ZoneId> {
250 let mut sim = drag_sim::<T>();
251 sim.pick_up_from(dom, payload, from);
252 rerender(dom);
253 for p in path {
254 sim.move_to(dom, *p);
255 rerender(dom);
256 }
257 let delivered = sim.release(dom);
258 rerender(dom);
259 delivered
260}