Skip to main content

dioxus_field/
testing.rs

1//! Reusable assertions for widget registry conformance tests.
2//!
3//! Registry tests wire these probes into their real components, drive the component through its
4//! normal interaction path, then call the corresponding assertion. The probes deliberately do not
5//! prescribe a rendered element or DOM event because those details belong to each widget.
6//!
7//! # Conformance levels
8//!
9//! The convention has two levels, and the kit certifies each:
10//!
11//! - **Trio-conformant** (no dependency on this crate): the widget honors the `value` /
12//!   `on_change` / `on_commit` prop trio plus attribute spread. Applicable tests:
13//!   [`CommitOrderProbe`] and [`ChangeOriginProbe`] (trio-only widgets imply
14//!   [`ChangeOrigin::User`]).
15//! - **Field-aware**: the widget additionally resolves the Field Context. Applicable tests: the
16//!   three resolution-precedence assertions, both [`FocusRoundTripProbe`] assertions, and
17//!   [`assert_field_part_ids`].
18//!
19//! Bindings that support Focus Exit can additionally use [`FocusExitProbe`] and
20//! [`FocusExitOrderProbe`]. These probes are optional: they do not add requirements to the
21//! dependency-free prop trio or to existing Commit-only field-aware conformance. Widget-specific
22//! logical-scope detection and deduplication remain the registry's responsibility.
23//!
24//! # Example
25//!
26//! The [runnable interaction-probe adapter] demonstrates how callbacks created during rendering
27//! reach a registry-owned driver. The [complete conformance test] exercises all six required tests
28//! and the optional Focus Exit tests against a minimal field-aware widget.
29//!
30//! [runnable interaction-probe adapter]: https://docs.rs/crate/dioxus-field/latest/source/examples/conformance.rs
31//! [complete conformance test]: https://docs.rs/crate/dioxus-field/latest/source/tests/conformance.rs
32
33use std::{
34    cell::{Cell, RefCell},
35    fmt::Debug,
36    rc::Rc,
37};
38
39use dioxus_core::{Attribute, AttributeValue, Callback};
40use dioxus_signals::ReadSignal;
41
42use crate::{Binding, ChangeOrigin, FieldControlOptions, FieldMeta};
43
44/// Records the relative order of a widget commit and its containing submit handler.
45#[derive(Clone, Debug, Default)]
46pub struct CommitOrderProbe {
47    events: Rc<RefCell<Vec<CommitOrderEvent>>>,
48}
49
50impl CommitOrderProbe {
51    /// Creates an empty commit-order probe.
52    pub fn new() -> Self {
53        Self::default()
54    }
55
56    /// Returns the callback to wire to the widget's `on_commit` path.
57    pub fn on_commit(&self) -> Callback<()> {
58        let events = Rc::clone(&self.events);
59        Callback::new(move |()| events.borrow_mut().push(CommitOrderEvent::Commit))
60    }
61
62    /// Returns the callback to invoke from the containing submit handler.
63    pub fn on_submit(&self) -> Callback<()> {
64        let events = Rc::clone(&self.events);
65        Callback::new(move |()| events.borrow_mut().push(CommitOrderEvent::Submit))
66    }
67
68    /// Asserts that one commit was synchronously observed before one submit.
69    ///
70    /// # Panics
71    ///
72    /// Panics when either callback was omitted, repeated, or observed out of order.
73    pub fn assert_commit_before_submit(&self) {
74        assert_eq!(
75            *self.events.borrow(),
76            [CommitOrderEvent::Commit, CommitOrderEvent::Submit],
77            "the widget must synchronously commit exactly once before submit handling runs"
78        );
79    }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83enum CommitOrderEvent {
84    Commit,
85    Submit,
86}
87
88/// Records reports that focus left a widget's complete logical focus scope.
89///
90/// This probe is optional and does not change Commit-only conformance. Registry tests can use
91/// [`FocusExitProbe::assert_no_focus_exit`] after moving focus between owned controls or popup
92/// content to verify that the widget retains the complete logical scope.
93#[derive(Clone, Debug, Default)]
94pub struct FocusExitProbe {
95    focus_exits: Rc<Cell<usize>>,
96}
97
98impl FocusExitProbe {
99    /// Creates an empty Focus Exit probe.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Returns the callback to supply through [`Binding::with_focus_exit`].
105    pub fn on_focus_exit(&self) -> Callback<()> {
106        let focus_exits = Rc::clone(&self.focus_exits);
107        Callback::new(move |()| focus_exits.set(focus_exits.get() + 1))
108    }
109
110    /// Asserts that Focus Exit was reported exactly once.
111    ///
112    /// # Panics
113    ///
114    /// Panics when the callback was omitted or invoked more than once.
115    pub fn assert_focus_exit_once(&self) {
116        assert_eq!(
117            self.focus_exits.get(),
118            1,
119            "focus leaving the widget's complete logical scope must be reported exactly once"
120        );
121    }
122
123    /// Asserts that Focus Exit was not reported.
124    ///
125    /// Use this after internal focus movement or a Commit that leaves focus inside the widget.
126    ///
127    /// # Panics
128    ///
129    /// Panics when Focus Exit was reported.
130    pub fn assert_no_focus_exit(&self) {
131        assert_eq!(
132            self.focus_exits.get(),
133            0,
134            "internal focus movement and Commit must not imply Focus Exit"
135        );
136    }
137}
138
139impl PartialEq for FocusExitProbe {
140    fn eq(&self, other: &Self) -> bool {
141        Rc::ptr_eq(&self.focus_exits, &other.focus_exits)
142    }
143}
144
145/// Records the relative order of binding writes, Commits, and Focus Exits.
146///
147/// This optional probe creates a [`Binding`] for a registry adapter to drive through its normal
148/// interaction path. Its write callback records the event but deliberately owns no value state;
149/// use [`ChangeOriginProbe`] separately when the test also needs to assert values and origins.
150#[derive(Clone, Debug, Default)]
151pub struct FocusExitOrderProbe {
152    events: Rc<RefCell<Vec<FocusExitOrderEvent>>>,
153}
154
155impl FocusExitOrderProbe {
156    /// Creates an empty Focus Exit order probe.
157    pub fn new() -> Self {
158        Self::default()
159    }
160
161    /// Creates a binding that records writes, Commits, and Focus Exits in call order.
162    pub fn binding<T: 'static>(&self, read: ReadSignal<T>) -> Binding<T> {
163        let write_events = Rc::clone(&self.events);
164        let commit_events = Rc::clone(&self.events);
165        let focus_exit_events = Rc::clone(&self.events);
166
167        Binding::new(
168            read,
169            Callback::new(move |_| {
170                write_events.borrow_mut().push(FocusExitOrderEvent::Write);
171            }),
172            Callback::new(move |()| {
173                commit_events.borrow_mut().push(FocusExitOrderEvent::Commit);
174            }),
175        )
176        .with_focus_exit(Callback::new(move |()| {
177            focus_exit_events
178                .borrow_mut()
179                .push(FocusExitOrderEvent::FocusExit);
180        }))
181    }
182
183    /// Asserts that one Commit occurred without a Focus Exit.
184    ///
185    /// # Panics
186    ///
187    /// Panics when Commit was omitted or repeated, or any write or Focus Exit was observed.
188    pub fn assert_commit_without_focus_exit(&self) {
189        assert_eq!(
190            *self.events.borrow(),
191            [FocusExitOrderEvent::Commit],
192            "Commit while focus remains in the widget must not imply Focus Exit"
193        );
194    }
195
196    /// Asserts that one synchronous write and Commit were observed before one Focus Exit.
197    ///
198    /// # Panics
199    ///
200    /// Panics when an event was omitted, repeated, or observed out of order.
201    pub fn assert_write_and_commit_before_focus_exit(&self) {
202        assert_eq!(
203            *self.events.borrow(),
204            [
205                FocusExitOrderEvent::Write,
206                FocusExitOrderEvent::Commit,
207                FocusExitOrderEvent::FocusExit,
208            ],
209            "Focus Exit must follow any synchronous widget write and Commit for the interaction"
210        );
211    }
212}
213
214#[derive(Clone, Copy, Debug, PartialEq, Eq)]
215enum FocusExitOrderEvent {
216    Write,
217    Commit,
218    FocusExit,
219}
220
221/// Records values written through a [`Binding`] together with their [`ChangeOrigin`].
222#[derive(Debug)]
223pub struct ChangeOriginProbe<T> {
224    writes: Rc<RefCell<Vec<(T, ChangeOrigin)>>>,
225}
226
227impl<T> ChangeOriginProbe<T> {
228    /// Creates an empty write probe.
229    pub fn new() -> Self {
230        Self::default()
231    }
232}
233
234impl<T: 'static> ChangeOriginProbe<T> {
235    /// Creates a binding whose writes are recorded by this probe.
236    pub fn binding(&self, read: ReadSignal<T>) -> Binding<T> {
237        self.binding_with_commit(read, Callback::new(|()| {}))
238    }
239
240    /// Creates a binding whose writes are recorded and whose commits use `on_commit`.
241    pub fn binding_with_commit(&self, read: ReadSignal<T>, on_commit: Callback<()>) -> Binding<T> {
242        let writes = Rc::clone(&self.writes);
243
244        Binding::new(
245            read,
246            Callback::new(move |write| writes.borrow_mut().push(write)),
247            on_commit,
248        )
249    }
250}
251
252impl<T: Debug + PartialEq> ChangeOriginProbe<T> {
253    /// Asserts the complete ordered sequence of value and origin pairs.
254    ///
255    /// # Panics
256    ///
257    /// Panics when the observed writes differ from `expected`.
258    pub fn assert_writes(&self, expected: &[(T, ChangeOrigin)]) {
259        assert_eq!(
260            self.writes.borrow().as_slice(),
261            expected,
262            "widget writes must retain their change origin"
263        );
264    }
265}
266
267impl<T> Clone for ChangeOriginProbe<T> {
268    fn clone(&self) -> Self {
269        Self {
270            writes: Rc::clone(&self.writes),
271        }
272    }
273}
274
275impl<T> Default for ChangeOriginProbe<T> {
276    fn default() -> Self {
277        Self {
278            writes: Rc::new(RefCell::new(Vec::new())),
279        }
280    }
281}
282
283/// The independently overridable metadata flags required by the convention.
284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285pub struct OverridableMetaFlags {
286    /// The resolved invalid state.
287    pub invalid: bool,
288    /// The resolved disabled state.
289    pub disabled: bool,
290    /// The resolved required state.
291    pub required: bool,
292}
293
294impl OverridableMetaFlags {
295    /// Creates one observed or expected set of metadata flags, with `required` left false.
296    pub const fn new(invalid: bool, disabled: bool) -> Self {
297        Self {
298            invalid,
299            disabled,
300            required: false,
301        }
302    }
303
304    /// Returns these flags with the resolved required state replaced.
305    #[must_use]
306    pub const fn with_required(mut self, required: bool) -> Self {
307        self.required = required;
308        self
309    }
310}
311
312/// Asserts explicit binding, context binding, then internal-state resolution precedence.
313///
314/// The registry adapter should expose the binding its widget resolved in the first two scenarios
315/// and the value observed after writing its uncontrolled binding in the final scenario.
316///
317/// # Panics
318///
319/// Panics when either resolved binding has the wrong identity or internal state did not retain its
320/// write.
321#[allow(
322    clippy::needless_pass_by_value,
323    reason = "owned observed values keep the assertion API convenient for registry tests"
324)]
325pub fn assert_binding_resolution_precedence<T: Debug + PartialEq + 'static>(
326    resolved_with_explicit: &Binding<T>,
327    explicit: &Binding<T>,
328    resolved_with_context: &Binding<T>,
329    context: &Binding<T>,
330    internal_value: T,
331    expected_internal_value: T,
332) {
333    assert!(
334        resolved_with_explicit == explicit,
335        "an explicit binding must win over Field Context"
336    );
337    assert!(
338        resolved_with_context == context,
339        "Field Context must win when no explicit binding is present"
340    );
341    assert_eq!(
342        internal_value, expected_internal_value,
343        "internal state must be used when neither an explicit binding nor Field Context is present"
344    );
345}
346
347/// Asserts explicit metadata, context metadata, then standalone metadata resolution precedence.
348///
349/// # Panics
350///
351/// Panics when either resolved metadata handle has the wrong identity or the standalone flags do
352/// not match the expected defaults.
353pub fn assert_meta_resolution_precedence(
354    resolved_with_explicit: FieldMeta,
355    explicit: FieldMeta,
356    resolved_with_context: FieldMeta,
357    context: FieldMeta,
358    standalone_flags: OverridableMetaFlags,
359    expected_standalone_flags: OverridableMetaFlags,
360) {
361    assert!(
362        resolved_with_explicit == explicit,
363        "explicit metadata must win over Field Context"
364    );
365    assert!(
366        resolved_with_context == context,
367        "Field Context metadata must win when explicit metadata is absent"
368    );
369    assert_eq!(
370        standalone_flags, expected_standalone_flags,
371        "standalone metadata must be used when neither explicit metadata nor Field Context is present"
372    );
373}
374
375/// Asserts the invalid and disabled flags observed after applying explicit per-flag props.
376///
377/// Registry tests should obtain `observed` from the actual attributes or state rendered by their
378/// widget, not by recomputing metadata resolution in the test.
379///
380/// # Panics
381///
382/// Panics when either observed flag differs from the expected explicit-or-metadata result.
383pub fn assert_meta_flag_precedence(observed: OverridableMetaFlags, expected: OverridableMetaFlags) {
384    assert_eq!(
385        observed, expected,
386        "each explicit metadata flag must override only its corresponding metadata flag"
387    );
388}
389
390/// Records focus callbacks reached through a widget's resolved [`crate::FocusRequest`].
391#[derive(Clone, Debug, Default)]
392pub struct FocusRoundTripProbe {
393    focus_calls: Rc<Cell<usize>>,
394}
395
396impl FocusRoundTripProbe {
397    /// Creates an empty focus probe.
398    pub fn new() -> Self {
399        Self::default()
400    }
401
402    /// Returns the callback the widget should register for its actual control.
403    pub fn on_focus(&self) -> Callback<()> {
404        let focus_calls = Rc::clone(&self.focus_calls);
405        Callback::new(move |()| focus_calls.set(focus_calls.get() + 1))
406    }
407
408    /// Asserts that one producer focus request reached the widget control callback.
409    ///
410    /// # Panics
411    ///
412    /// Panics when the focus callback was omitted or invoked more than once.
413    pub fn assert_focus_round_trip(&self) {
414        assert_eq!(
415            self.focus_calls.get(),
416            1,
417            "one producer focus request must reach the widget's control exactly once"
418        );
419    }
420
421    /// Asserts that no producer focus request moved focus.
422    ///
423    /// Drive the widget's focus request while it is disabled, then call this. A disabled control
424    /// must focus nothing: focusing a proxy element instead pulls focus off whatever the user was
425    /// on, and `HTMLElement.focus()` reports success on a disabled element, so nothing downstream
426    /// can detect the difference.
427    ///
428    /// # Panics
429    ///
430    /// Panics when the widget moved focus anyway.
431    pub fn assert_focus_not_moved(&self) {
432        assert_eq!(
433            self.focus_calls.get(),
434            0,
435            "a focus request must not move focus while the control is disabled"
436        );
437    }
438}
439
440impl PartialEq for FocusRoundTripProbe {
441    fn eq(&self, other: &Self) -> bool {
442        Rc::ptr_eq(&self.focus_calls, &other.focus_calls)
443    }
444}
445
446/// Asserts the description and error ids currently registered in field metadata.
447///
448/// Call this once after the registry's description and error parts mount, then again with empty
449/// expected slices after they drop. Id order must match mount order because ARIA id references are
450/// rendered in registration order.
451///
452/// While the field is invalid, `aria-describedby` carries the description ids followed by every
453/// error id, and `aria-errormessage` carries only the first error id — it takes a single IDREF in
454/// ARIA 1.2, so a list there is malformed and exposes no error at all.
455///
456/// # Panics
457///
458/// Panics when the metadata's ARIA id references differ from the expected ids.
459pub fn assert_field_part_ids(
460    meta: FieldMeta,
461    expected_description_ids: &[&str],
462    expected_error_ids: &[&str],
463) {
464    let attributes = meta.attributes_for(&FieldControlOptions::new().invalid(Some(true)));
465    let mut expected_described_by = expected_description_ids.to_vec();
466    expected_described_by.extend_from_slice(expected_error_ids);
467
468    assert_eq!(
469        attribute_text(&attributes, "aria-describedby"),
470        joined_ids(&expected_described_by),
471        "description ids, then error ids, must match the currently mounted parts"
472    );
473    assert_eq!(
474        attribute_text(&attributes, "aria-errormessage").as_deref(),
475        expected_error_ids.first().copied(),
476        "aria-errormessage must reference the first mounted error part and nothing else"
477    );
478}
479
480fn attribute_text(attributes: &[Attribute], name: &str) -> Option<String> {
481    attributes
482        .iter()
483        .find(|attribute| attribute.name == name)
484        .and_then(|attribute| match &attribute.value {
485            AttributeValue::Text(value) => Some(value.clone()),
486            _ => None,
487        })
488}
489
490fn joined_ids(ids: &[&str]) -> Option<String> {
491    (!ids.is_empty()).then(|| ids.join(" "))
492}