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//! # Example
20//!
21//! The [runnable interaction-probe adapter] demonstrates how callbacks created during rendering
22//! reach a registry-owned driver. The [complete conformance test] exercises all six required tests
23//! against a minimal field-aware widget.
24//!
25//! [runnable interaction-probe adapter]: https://docs.rs/crate/dioxus-field/latest/source/examples/conformance.rs
26//! [complete conformance test]: https://docs.rs/crate/dioxus-field/latest/source/tests/conformance.rs
27
28use std::{
29    cell::{Cell, RefCell},
30    fmt::Debug,
31    rc::Rc,
32};
33
34use dioxus_core::{Attribute, AttributeValue, Callback};
35use dioxus_signals::ReadSignal;
36
37use crate::{Binding, ChangeOrigin, FieldControlOptions, FieldMeta};
38
39/// Records the relative order of a widget commit and its containing submit handler.
40#[derive(Clone, Debug, Default)]
41pub struct CommitOrderProbe {
42    events: Rc<RefCell<Vec<CommitOrderEvent>>>,
43}
44
45impl CommitOrderProbe {
46    /// Creates an empty commit-order probe.
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Returns the callback to wire to the widget's `on_commit` path.
52    pub fn on_commit(&self) -> Callback<()> {
53        let events = Rc::clone(&self.events);
54        Callback::new(move |()| events.borrow_mut().push(CommitOrderEvent::Commit))
55    }
56
57    /// Returns the callback to invoke from the containing submit handler.
58    pub fn on_submit(&self) -> Callback<()> {
59        let events = Rc::clone(&self.events);
60        Callback::new(move |()| events.borrow_mut().push(CommitOrderEvent::Submit))
61    }
62
63    /// Asserts that one commit was synchronously observed before one submit.
64    ///
65    /// # Panics
66    ///
67    /// Panics when either callback was omitted, repeated, or observed out of order.
68    pub fn assert_commit_before_submit(&self) {
69        assert_eq!(
70            *self.events.borrow(),
71            [CommitOrderEvent::Commit, CommitOrderEvent::Submit],
72            "the widget must synchronously commit exactly once before submit handling runs"
73        );
74    }
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78enum CommitOrderEvent {
79    Commit,
80    Submit,
81}
82
83/// Records values written through a [`Binding`] together with their [`ChangeOrigin`].
84#[derive(Debug)]
85pub struct ChangeOriginProbe<T> {
86    writes: Rc<RefCell<Vec<(T, ChangeOrigin)>>>,
87}
88
89impl<T> ChangeOriginProbe<T> {
90    /// Creates an empty write probe.
91    pub fn new() -> Self {
92        Self::default()
93    }
94}
95
96impl<T: 'static> ChangeOriginProbe<T> {
97    /// Creates a binding whose writes are recorded by this probe.
98    pub fn binding(&self, read: ReadSignal<T>) -> Binding<T> {
99        self.binding_with_commit(read, Callback::new(|()| {}))
100    }
101
102    /// Creates a binding whose writes are recorded and whose commits use `on_commit`.
103    pub fn binding_with_commit(&self, read: ReadSignal<T>, on_commit: Callback<()>) -> Binding<T> {
104        let writes = Rc::clone(&self.writes);
105
106        Binding::new(
107            read,
108            Callback::new(move |write| writes.borrow_mut().push(write)),
109            on_commit,
110        )
111    }
112}
113
114impl<T: Debug + PartialEq> ChangeOriginProbe<T> {
115    /// Asserts the complete ordered sequence of value and origin pairs.
116    ///
117    /// # Panics
118    ///
119    /// Panics when the observed writes differ from `expected`.
120    pub fn assert_writes(&self, expected: &[(T, ChangeOrigin)]) {
121        assert_eq!(
122            self.writes.borrow().as_slice(),
123            expected,
124            "widget writes must retain their change origin"
125        );
126    }
127}
128
129impl<T> Clone for ChangeOriginProbe<T> {
130    fn clone(&self) -> Self {
131        Self {
132            writes: Rc::clone(&self.writes),
133        }
134    }
135}
136
137impl<T> Default for ChangeOriginProbe<T> {
138    fn default() -> Self {
139        Self {
140            writes: Rc::new(RefCell::new(Vec::new())),
141        }
142    }
143}
144
145/// The independently overridable metadata flags required by the convention.
146#[derive(Clone, Copy, Debug, PartialEq, Eq)]
147pub struct OverridableMetaFlags {
148    /// The resolved invalid state.
149    pub invalid: bool,
150    /// The resolved disabled state.
151    pub disabled: bool,
152    /// The resolved required state.
153    pub required: bool,
154}
155
156impl OverridableMetaFlags {
157    /// Creates one observed or expected set of metadata flags, with `required` left false.
158    pub const fn new(invalid: bool, disabled: bool) -> Self {
159        Self {
160            invalid,
161            disabled,
162            required: false,
163        }
164    }
165
166    /// Returns these flags with the resolved required state replaced.
167    #[must_use]
168    pub const fn with_required(mut self, required: bool) -> Self {
169        self.required = required;
170        self
171    }
172}
173
174/// Asserts explicit binding, context binding, then internal-state resolution precedence.
175///
176/// The registry adapter should expose the binding its widget resolved in the first two scenarios
177/// and the value observed after writing its uncontrolled binding in the final scenario.
178///
179/// # Panics
180///
181/// Panics when either resolved binding has the wrong identity or internal state did not retain its
182/// write.
183#[allow(
184    clippy::needless_pass_by_value,
185    reason = "owned observed values keep the assertion API convenient for registry tests"
186)]
187pub fn assert_binding_resolution_precedence<T: Debug + PartialEq + 'static>(
188    resolved_with_explicit: &Binding<T>,
189    explicit: &Binding<T>,
190    resolved_with_context: &Binding<T>,
191    context: &Binding<T>,
192    internal_value: T,
193    expected_internal_value: T,
194) {
195    assert!(
196        resolved_with_explicit == explicit,
197        "an explicit binding must win over Field Context"
198    );
199    assert!(
200        resolved_with_context == context,
201        "Field Context must win when no explicit binding is present"
202    );
203    assert_eq!(
204        internal_value, expected_internal_value,
205        "internal state must be used when neither an explicit binding nor Field Context is present"
206    );
207}
208
209/// Asserts explicit metadata, context metadata, then standalone metadata resolution precedence.
210///
211/// # Panics
212///
213/// Panics when either resolved metadata handle has the wrong identity or the standalone flags do
214/// not match the expected defaults.
215pub fn assert_meta_resolution_precedence(
216    resolved_with_explicit: FieldMeta,
217    explicit: FieldMeta,
218    resolved_with_context: FieldMeta,
219    context: FieldMeta,
220    standalone_flags: OverridableMetaFlags,
221    expected_standalone_flags: OverridableMetaFlags,
222) {
223    assert!(
224        resolved_with_explicit == explicit,
225        "explicit metadata must win over Field Context"
226    );
227    assert!(
228        resolved_with_context == context,
229        "Field Context metadata must win when explicit metadata is absent"
230    );
231    assert_eq!(
232        standalone_flags, expected_standalone_flags,
233        "standalone metadata must be used when neither explicit metadata nor Field Context is present"
234    );
235}
236
237/// Asserts the invalid and disabled flags observed after applying explicit per-flag props.
238///
239/// Registry tests should obtain `observed` from the actual attributes or state rendered by their
240/// widget, not by recomputing metadata resolution in the test.
241///
242/// # Panics
243///
244/// Panics when either observed flag differs from the expected explicit-or-metadata result.
245pub fn assert_meta_flag_precedence(observed: OverridableMetaFlags, expected: OverridableMetaFlags) {
246    assert_eq!(
247        observed, expected,
248        "each explicit metadata flag must override only its corresponding metadata flag"
249    );
250}
251
252/// Records focus callbacks reached through a widget's resolved [`crate::FocusRequest`].
253#[derive(Clone, Debug, Default)]
254pub struct FocusRoundTripProbe {
255    focus_calls: Rc<Cell<usize>>,
256}
257
258impl FocusRoundTripProbe {
259    /// Creates an empty focus probe.
260    pub fn new() -> Self {
261        Self::default()
262    }
263
264    /// Returns the callback the widget should register for its actual control.
265    pub fn on_focus(&self) -> Callback<()> {
266        let focus_calls = Rc::clone(&self.focus_calls);
267        Callback::new(move |()| focus_calls.set(focus_calls.get() + 1))
268    }
269
270    /// Asserts that one producer focus request reached the widget control callback.
271    ///
272    /// # Panics
273    ///
274    /// Panics when the focus callback was omitted or invoked more than once.
275    pub fn assert_focus_round_trip(&self) {
276        assert_eq!(
277            self.focus_calls.get(),
278            1,
279            "one producer focus request must reach the widget's control exactly once"
280        );
281    }
282
283    /// Asserts that no producer focus request moved focus.
284    ///
285    /// Drive the widget's focus request while it is disabled, then call this. A disabled control
286    /// must focus nothing: focusing a proxy element instead pulls focus off whatever the user was
287    /// on, and `HTMLElement.focus()` reports success on a disabled element, so nothing downstream
288    /// can detect the difference.
289    ///
290    /// # Panics
291    ///
292    /// Panics when the widget moved focus anyway.
293    pub fn assert_focus_not_moved(&self) {
294        assert_eq!(
295            self.focus_calls.get(),
296            0,
297            "a focus request must not move focus while the control is disabled"
298        );
299    }
300}
301
302impl PartialEq for FocusRoundTripProbe {
303    fn eq(&self, other: &Self) -> bool {
304        Rc::ptr_eq(&self.focus_calls, &other.focus_calls)
305    }
306}
307
308/// Asserts the description and error ids currently registered in field metadata.
309///
310/// Call this once after the registry's description and error parts mount, then again with empty
311/// expected slices after they drop. Id order must match mount order because ARIA id references are
312/// rendered in registration order.
313///
314/// While the field is invalid, `aria-describedby` carries the description ids followed by every
315/// error id, and `aria-errormessage` carries only the first error id — it takes a single IDREF in
316/// ARIA 1.2, so a list there is malformed and exposes no error at all.
317///
318/// # Panics
319///
320/// Panics when the metadata's ARIA id references differ from the expected ids.
321pub fn assert_field_part_ids(
322    meta: FieldMeta,
323    expected_description_ids: &[&str],
324    expected_error_ids: &[&str],
325) {
326    let attributes = meta.attributes_for(&FieldControlOptions::new().invalid(Some(true)));
327    let mut expected_described_by = expected_description_ids.to_vec();
328    expected_described_by.extend_from_slice(expected_error_ids);
329
330    assert_eq!(
331        attribute_text(&attributes, "aria-describedby"),
332        joined_ids(&expected_described_by),
333        "description ids, then error ids, must match the currently mounted parts"
334    );
335    assert_eq!(
336        attribute_text(&attributes, "aria-errormessage").as_deref(),
337        expected_error_ids.first().copied(),
338        "aria-errormessage must reference the first mounted error part and nothing else"
339    );
340}
341
342fn attribute_text(attributes: &[Attribute], name: &str) -> Option<String> {
343    attributes
344        .iter()
345        .find(|attribute| attribute.name == name)
346        .and_then(|attribute| match &attribute.value {
347            AttributeValue::Text(value) => Some(value.clone()),
348            _ => None,
349        })
350}
351
352fn joined_ids(ids: &[&str]) -> Option<String> {
353    (!ids.is_empty()).then(|| ids.join(" "))
354}