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}