Skip to main content

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}