Skip to main content

dioxus_dnd/
test.rs

1#![doc = include_str!("../docs/api/testing.md")]
2
3use std::any::{Any, TypeId};
4use std::cell::RefCell;
5use std::collections::HashMap;
6
7use dioxus::prelude::*;
8
9use crate::core::components::{
10    deliver_drop, drop_query, resolve_drag_hover, resolve_drag_target, DropCompletion, SettleRoute,
11};
12use crate::core::hooks::SettleFlag;
13use crate::core::monitor::CancelReason;
14use crate::core::world::{JoinedWindow, WorldHit, WorldMembership};
15use crate::core::{
16    use_dnd, use_zone_registry, DndContext, DragCompletion, DropEffect, Point, Rect, WindowKey,
17    ZoneId, ZoneRegistry,
18};
19
20thread_local! {
21    /// Handles captured by [`DragSimProbe`], keyed by payload type. One
22    /// slot per type per thread: the most recently mounted probe wins,
23    /// which is exactly right for one `VirtualDom` per test.
24    static SIMS: RefCell<HashMap<TypeId, Box<dyn Any>>> = RefCell::new(HashMap::new());
25}
26
27/// Captures a [`DragSim`] for the enclosing provider. Mount one inside the
28/// `DndProvider<T>` of your *test* app (it renders nothing), then retrieve
29/// the handle with [`drag_sim`] after `rebuild_in_place`.
30#[component]
31pub fn DragSimProbe<T: Clone + PartialEq + 'static>(
32    /// Internal marker; never set this.
33    #[props(default)]
34    phantom: std::marker::PhantomData<T>,
35) -> Element {
36    let _ = phantom;
37    let completions = use_signal(Vec::<bool>::new);
38    let completion = use_callback(move |dropped| {
39        let mut completions = completions;
40        completions.write().push(dropped);
41    });
42    let sim = DragSim {
43        dnd: use_dnd::<T>(),
44        registry: use_zone_registry::<T>(),
45        settle: try_use_context::<SettleFlag<T>>(),
46        membership: try_use_context::<WorldMembership<T>>().and_then(|m| m.0),
47        completion,
48        completions,
49    };
50    use_hook(move || {
51        SIMS.with_borrow_mut(|m| {
52            m.insert(TypeId::of::<T>(), Box::new(sim));
53        });
54    });
55    rsx! {}
56}
57
58/// The handle the most recent [`DragSimProbe<T>`] captured.
59///
60/// # Panics
61/// Panics when no probe for `T` has mounted - add `DragSimProbe::<T> {}`
62/// inside the provider and `rebuild_in_place` first.
63pub fn drag_sim<T: Clone + PartialEq + 'static>() -> DragSim<T> {
64    SIMS.with_borrow(|m| {
65        m.get(&TypeId::of::<T>())
66            .and_then(|b| b.downcast_ref::<DragSim<T>>())
67            .copied()
68    })
69    .expect("no DragSim captured: mount DragSimProbe::<T> inside the provider and rebuild first")
70}
71
72/// Headless driver for one provider's drag world. Every method takes the
73/// `VirtualDom` so the underlying signal operations run inside its runtime;
74/// call [`rerender`] between actions and markup assertions.
75pub struct DragSim<T: Clone + 'static> {
76    dnd: DndContext<T>,
77    registry: ZoneRegistry<T>,
78    settle: Option<SettleFlag<T>>,
79    /// The provider's world membership, when it joined a `DndWorld` -
80    /// moves and releases then resolve across windows, like the gesture.
81    membership: Option<JoinedWindow<T>>,
82    completion: Callback<bool>,
83    completions: Signal<Vec<bool>>,
84}
85
86impl<T: Clone + 'static> Copy for DragSim<T> {}
87impl<T: Clone + 'static> Clone for DragSim<T> {
88    fn clone(&self) -> Self {
89        *self
90    }
91}
92
93impl<T: Clone + PartialEq + 'static> DragSim<T> {
94    /// Give a zone its client rect - the headless stand-in for layout.
95    ///
96    /// # Panics
97    /// Panics when no zone with this id is registered.
98    pub fn place(&self, dom: &VirtualDom, zone: ZoneId, rect: Rect) {
99        dom.in_runtime(|| {
100            assert!(
101                self.registry.contains(zone),
102                "place: no zone {} registered",
103                zone.0
104            );
105            let mut registry = self.registry;
106            registry.set_rect(zone, rect);
107        });
108    }
109
110    /// The key this sim's provider joined its world under, when it did.
111    pub fn window_key(&self) -> Option<WindowKey> {
112        self.membership.map(|j| j.key)
113    }
114
115    /// [`Self::place`] for a zone living in another joined window's
116    /// registry - `rect` is in **that window's** client px.
117    ///
118    /// # Panics
119    /// Panics when this sim's provider joined no world, the window is
120    /// unknown, or the zone isn't registered there.
121    pub fn place_in(&self, dom: &VirtualDom, window: WindowKey, zone: ZoneId, rect: Rect) {
122        let world = self
123            .membership
124            .expect("place_in: this provider joined no DndWorld")
125            .world;
126        dom.in_runtime(|| {
127            let rec = world
128                .record(window)
129                .unwrap_or_else(|| panic!("place_in: no window {} joined", window.0));
130            assert!(
131                rec.registry.contains(zone),
132                "place_in: no zone {} in window {}",
133                zone.0,
134                window.0
135            );
136            let mut registry = rec.registry;
137            registry.set_rect(zone, rect);
138        });
139    }
140
141    /// Begin a pointer drag carrying `payload`, from no particular zone.
142    pub fn pick_up(&mut self, dom: &VirtualDom, payload: T) {
143        self.pick_up_from(dom, payload, None);
144    }
145
146    /// Begin a pointer drag, reporting `from` as the source zone
147    /// (arrives in `DropOutcome::from`).
148    pub fn pick_up_from(&mut self, dom: &VirtualDom, payload: T, from: Option<ZoneId>) {
149        let mut dnd = self.dnd;
150        let membership = self.membership;
151        dom.in_runtime(|| {
152            let session = dnd.start_tracked(
153                payload,
154                from,
155                Point::default(),
156                Point::default(),
157                DropEffect::Move,
158                self.completion,
159            );
160            // Like the gesture: a world drag anchors to this window.
161            if dnd.is_session(session) {
162                if let Some(j) = membership {
163                    j.world.begin_from(j.key);
164                }
165            }
166        });
167    }
168
169    /// Move the pointer: updates the tracked position and enters/leaves
170    /// zones by hit-testing the placed rects - the same logic the pointer
171    /// gesture runs per `pointermove`.
172    pub fn move_to(&mut self, dom: &VirtualDom, point: Point) {
173        let mut dnd = self.dnd;
174        let registry = self.registry;
175        let membership = self.membership;
176        dom.in_runtime(|| {
177            let session = dnd.active_session();
178            dnd.update_pointer(point);
179            if session.is_some_and(|session| !dnd.is_session(session)) {
180                return;
181            }
182            let query = dnd
183                .payload()
184                .map(|payload| drop_query(&dnd, payload, dnd.effect()));
185            // Same resolution order as the gesture: world hits (any
186            // window) are authoritative, unresolved points fall back to
187            // the local registry.
188            match membership {
189                Some(joined) => match query
190                    .as_ref()
191                    .map(|query| joined.zone_under_query(point, query))
192                    .unwrap_or(WorldHit::Unresolved)
193                {
194                    WorldHit::Zone(location) => joined.enter(location),
195                    WorldHit::Window => joined.clear_hover(),
196                    WorldHit::Unresolved => {
197                        match resolve_drag_hover(registry, &dnd, point, dnd.effect()) {
198                            Some(zone) => joined.enter(joined.location(zone)),
199                            None => joined.clear_hover(),
200                        }
201                    }
202                },
203                None => match resolve_drag_hover(registry, &dnd, point, dnd.effect()) {
204                    Some(zone) => dnd.enter(zone),
205                    None => {
206                        if let Some(over) = dnd.over() {
207                            dnd.leave(over);
208                        }
209                    }
210                },
211            }
212        });
213    }
214
215    /// Release at the current pointer position. Returns the zone that
216    /// received the drop, or `None` when the drag cancelled (no acceptable
217    /// zone under the pointer or within the provider's configured recovery
218    /// radius).
219    pub fn release(&mut self, dom: &VirtualDom) -> Option<ZoneId> {
220        self.release_as(dom, DropEffect::Move)
221    }
222
223    /// [`Self::release`] with an explicit effect - simulate the Ctrl-held
224    /// copy drop with `DropEffect::Copy`.
225    pub fn release_as(&mut self, dom: &VirtualDom, effect: DropEffect) -> Option<ZoneId> {
226        let mut dnd = self.dnd;
227        let registry = self.registry;
228        let settle = self.settle;
229        let membership = self.membership;
230        dom.in_runtime(|| {
231            let point = dnd.pointer();
232            let session = dnd.active_session();
233            // A release the world resolves into a foreign window delivers
234            // there, mirroring the gesture (the snap runs in the target
235            // window's own CSS px). Headless rects are placed, so the
236            // gesture's pre-snap re-measure is skipped as documented.
237            if let Some(j) = membership {
238                let _ = j.zone_under(point);
239                if let Some((rec, local)) = j.foreign_window_under(point) {
240                    let target = dnd.payload().and_then(|payload| {
241                        let query = drop_query(&dnd, payload, effect);
242                        rec.registry
243                            .resolve(
244                                &query,
245                                local,
246                                j.world.active_rect_in(rec, local),
247                                rec.registry.release_policy().recovery_radius,
248                            )
249                            .map(|(zone, _)| zone)
250                    });
251                    let delivered = target
252                        .filter(|t| {
253                            deliver_drop(
254                                rec.registry,
255                                &mut dnd,
256                                SettleRoute {
257                                    flag: Some(rec.settle),
258                                    owner: Some((&j.world, rec.key)),
259                                },
260                                DropCompletion::World {
261                                    world: &j.world,
262                                    session,
263                                },
264                                *t,
265                                local,
266                                effect,
267                            )
268                        })
269                        .is_some();
270                    if !delivered {
271                        match session {
272                            Some(session) => {
273                                j.world.finish_session(
274                                    session,
275                                    DragCompletion::Cancelled(CancelReason::NoTarget),
276                                );
277                            }
278                            None => j.world.finish_untracked(DragCompletion::Cancelled(
279                                CancelReason::NoTarget,
280                            )),
281                        }
282                        return None;
283                    }
284                    return target;
285                }
286            }
287            let target = resolve_drag_target(
288                registry,
289                &dnd,
290                point,
291                effect,
292                registry.release_policy().recovery_radius,
293            );
294            let delivered = target
295                .filter(|t| match membership {
296                    Some(j) => deliver_drop(
297                        registry,
298                        &mut dnd,
299                        SettleRoute {
300                            flag: settle,
301                            owner: Some((&j.world, j.key)),
302                        },
303                        DropCompletion::World {
304                            world: &j.world,
305                            session,
306                        },
307                        *t,
308                        point,
309                        effect,
310                    ),
311                    None => deliver_drop(
312                        registry,
313                        &mut dnd,
314                        SettleRoute {
315                            flag: settle,
316                            owner: None,
317                        },
318                        match session {
319                            Some(session) => DropCompletion::Local(session),
320                            None => DropCompletion::None,
321                        },
322                        *t,
323                        point,
324                        effect,
325                    ),
326                })
327                .is_some();
328            if !delivered {
329                match membership {
330                    Some(j) => match session {
331                        Some(session) => {
332                            j.world.finish_session(
333                                session,
334                                DragCompletion::Cancelled(CancelReason::NoTarget),
335                            );
336                        }
337                        None => j
338                            .world
339                            .finish_untracked(DragCompletion::Cancelled(CancelReason::NoTarget)),
340                    },
341                    None => match session {
342                        Some(session) => {
343                            dnd.cancel_session(session, CancelReason::NoTarget);
344                        }
345                        None => dnd.cancel_with_reason(CancelReason::NoTarget),
346                    },
347                }
348                return None;
349            }
350            target
351        })
352    }
353
354    /// Abort the drag, as Escape or a pointer cancel would.
355    pub fn cancel(&mut self, dom: &VirtualDom) {
356        let mut dnd = self.dnd;
357        let membership = self.membership;
358        dom.in_runtime(|| {
359            let session = dnd.active_session();
360            match membership {
361                Some(j) => match session {
362                    Some(session) => {
363                        j.world
364                            .finish_session(session, DragCompletion::Cancelled(CancelReason::User));
365                    }
366                    None => {
367                        j.world
368                            .finish_untracked(DragCompletion::Cancelled(CancelReason::User));
369                    }
370                },
371                None => match session {
372                    Some(session) => {
373                        dnd.cancel_session(session, CancelReason::User);
374                    }
375                    None => dnd.cancel(),
376                },
377            }
378        });
379    }
380
381    /// Exactly-once source completion results observed by the simulated
382    /// source (`true` for delivered, `false` for cancelled).
383    pub fn completions(&self, dom: &VirtualDom) -> Vec<bool> {
384        dom.in_runtime(|| self.completions.read().clone())
385    }
386
387    /// The zone currently hovered.
388    pub fn over(&self, dom: &VirtualDom) -> Option<ZoneId> {
389        dom.in_runtime(|| self.dnd.over())
390    }
391
392    /// Is a drag in flight?
393    pub fn dragging(&self, dom: &VirtualDom) -> bool {
394        dom.in_runtime(|| self.dnd.dragging())
395    }
396
397    /// The in-flight payload, if any.
398    pub fn payload(&self, dom: &VirtualDom) -> Option<T> {
399        dom.in_runtime(|| self.dnd.payload())
400    }
401
402    /// The latest screen-reader announcement.
403    pub fn announcement(&self, dom: &VirtualDom) -> String {
404        dom.in_runtime(|| self.dnd.announcement())
405    }
406}
407
408/// Flush pending reactivity so the tree reflects the simulated state -
409/// call between driver actions and markup assertions
410/// (`dioxus_ssr::render`).
411pub fn rerender(dom: &mut VirtualDom) {
412    dom.process_events();
413    dom.render_immediate(&mut dioxus::core::NoOpMutations);
414}
415
416/// One whole pointer drag: pick `payload` up (from `from`), glide through
417/// `path`, release at its last point, re-rendering between steps so zone
418/// reactions run just as they would live. Returns the receiving zone, or
419/// `None` when the drag cancelled. Needs a mounted [`DragSimProbe<T>`];
420/// an empty `path` releases at the pickup point.
421pub fn simulate_drag<T: Clone + PartialEq + 'static>(
422    dom: &mut VirtualDom,
423    payload: T,
424    from: Option<ZoneId>,
425    path: &[Point],
426) -> Option<ZoneId> {
427    let mut sim = drag_sim::<T>();
428    sim.pick_up_from(dom, payload, from);
429    rerender(dom);
430    for p in path {
431        sim.move_to(dom, *p);
432        rerender(dom);
433    }
434    let delivered = sim.release(dom);
435    rerender(dom);
436    delivered
437}