Skip to main content

teksilo_core/widget_tree/
accessibility_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6use crate::accessibility::{AccessNodeBuilder, AccessibilityInfo};
7
8/// A Teksilo rect as AccessKit expresses one: two corners rather than an
9/// origin and a size.
10pub(super) fn to_accesskit_rect(rect: teksilo_canvas::Rect) -> accesskit::Rect {
11    accesskit::Rect {
12        x0: rect.x as f64,
13        y0: rect.y as f64,
14        x1: (rect.x + rect.width) as f64,
15        y1: (rect.y + rect.height) as f64,
16    }
17}
18
19impl WidgetTree {
20    /// Build an AccessKit `TreeUpdate` from the current state of all active
21    /// widgets. Call this once per frame, between layout and paint, and push
22    /// the result to the `accesskit_winit::Adapter`.
23    /// Caches the result and rebuilds only when something that actually
24    /// changes the AT tree has happened: a focus move, an overlay change, a
25    /// widget rebuild, an active↔dormant transition, an `AccessibilityOnly`
26    /// binding flip, a shortcut rebind, a locale switch, a queued
27    /// announcement, or an explicit
28    /// [`request_accessibility_update`](Self::request_accessibility_update).
29    /// A plain relayout does not invalidate the cache.
30    pub fn sync_accessibility(&mut self) -> accesskit::TreeUpdate {
31        // Explicit re-walk request (e.g. `SceneView` materialised / destroyed a
32        // scene widget, or an a11y-only scene mutation). A relayout no longer
33        // sets `a11y_dirty` on its own, so this is the lever; drain it before
34        // the cache check below.
35        if self.a11y_update_requested.replace(false) {
36            self.a11y_dirty = true;
37        }
38        // Shortcut registry rebinds bump
39        // `ShortcutRegistry::version()`. The `access_shortcut_id`
40        // resolution in the walker reads the live registry, so any
41        // rebind must invalidate the AT-tree cache too — otherwise
42        // a rebind would not surface in the announced shortcut
43        // until something else dirties the tree (a layout, a focus
44        // change, …). Cheap: one u64 compare per `sync_accessibility`.
45        let current_shortcut_version = self.shortcut_registry.version().get();
46        if current_shortcut_version != self.last_synced_shortcut_version {
47            self.a11y_dirty = true;
48            self.last_synced_shortcut_version = current_shortcut_version;
49        }
50
51        // Locale switches make `access_label(tr!(...))` (stored as a
52        // locale-bound `Prop<String>`) resolve to a new value. The
53        // override props are read in `apply()` during the walk, so the
54        // tree must re-walk for the new announcement to surface —
55        // otherwise the screen reader keeps the old-locale string until
56        // something else dirties the tree. Mirror the shortcut-version
57        // guard above. (Same-direction switches don't rebuild the
58        // composite, so this is the only thing that refreshes AT labels.)
59        let current_locale = self.locale_signal.get();
60        if current_locale != self.last_synced_locale {
61            self.a11y_dirty = true;
62            self.last_synced_locale = current_locale;
63        }
64
65        // The framework's own live regions. Advancing them here, before the
66        // cache check, is what makes a queued announcement able to wake a tree
67        // that is otherwise clean: `announce` sets `a11y_update_requested`,
68        // which the drain above turned into `a11y_dirty`, and the step below
69        // decides what this update will say. Each message costs two updates —
70        // one that exposes its node, one that retracts it — so an announcer
71        // that is still busy asks for another sync and another frame.
72        let announcers_busy = self.step_announcers();
73
74        if !self.a11y_dirty
75            && let Some(cached) = &self.cached_a11y
76        {
77            // Nothing about a widget's *content* depends on where it sits,
78            // so a pure translation is re-placed in the cached tree rather
79            // than rebuilt: the alternative — walking on every scroll frame
80            // — was too expensive to do, so it was not done, and every
81            // node's bounds went stale the moment anything scrolled.
82            //
83            // The full-tree contract is unchanged. Around twenty callers
84            // read this return value as a complete description of the tree
85            // (`wait_for_condition`, `snapshot_json`, `find_node`, the
86            // headless harness), and the consumer panics on an update
87            // naming a node it has never seen, so a partial update is not
88            // an option here.
89            let _ = cached;
90            self.patch_accessibility_bounds();
91            return self
92                .cached_a11y
93                .as_ref()
94                .expect("the cache was present a line ago")
95                .clone();
96        }
97
98        let (update, parents, local_bounds) = self.build_accessibility_tree();
99        // A `&mut self` post-pass: diff the freshly-built live nodes and
100        // record any changed text into the announcement ring buffer. Must
101        // run here (not in the `&self` `build_accessibility_tree`) and
102        // before the cache store, so it sees exactly the update that is
103        // about to become canonical.
104        self.collect_announcements(&update);
105        // Bump the AT version only when the tree's *content* actually changed.
106        // A rebuild can be triggered by a shortcut-rebind / locale switch that
107        // produces a byte-for-byte identical `TreeUpdate`; bumping then would
108        // make `WaitCondition::AtVersionAtLeast` wake on no real change.
109        // `TreeUpdate: PartialEq`, so this is a precise (if O(n)) comparison —
110        // acceptable since it only runs on a real rebuild, not a cache hit.
111        let content_changed = self.cached_a11y.as_ref() != Some(&update);
112        self.cached_a11y = Some(update.clone());
113        self.synthetic_parent_map = parents;
114        self.synthetic_local_bounds = local_bounds;
115        self.a11y_dirty = false;
116        // A walk has just described every node's current position, so the
117        // moves recorded up to now are already reflected.
118        self.arena.take_a11y_moved();
119        self.arena.take_a11y_resized();
120        self.a11y_walk_generation = self.a11y_walk_generation.saturating_add(1);
121        if content_changed {
122            // Mirror of the shortcut-registry version signal; saturating so the
123            // documented "monotonic" contract holds even past `u64::MAX`.
124            self.at_version.set(self.at_version.get().saturating_add(1));
125        }
126        if announcers_busy {
127            self.request_accessibility_update();
128            self.request_frame();
129        }
130        update
131    }
132
133    /// How many accessibility walks have happened.
134    ///
135    /// A delivery that has not seen the latest walk is holding a tree whose
136    /// *shape* may be stale, not merely its geometry — so it must push the
137    /// full tree rather than rely on the geometry patch.
138    pub fn a11y_walk_generation(&self) -> u64 {
139        self.a11y_walk_generation
140    }
141
142    /// Re-place the nodes that moved, in the cached tree, without walking.
143    ///
144    /// Only geometry changes here: `at_version` is not bumped (its
145    /// contract is *semantic* change), no announcement is collected, and
146    /// the next full walk's `PartialEq` comparison stays correct because
147    /// the cache already holds the new bounds.
148    ///
149    /// A moved id absent from the cache is skipped rather than inserted:
150    /// it is a node the walk deliberately left out — a pruned layout
151    /// stack, an excluded or merged descendant — and inventing an entry
152    /// for it would put a node in the update that has no parent.
153    fn patch_accessibility_bounds(&mut self) {
154        let moved = self.arena.take_a11y_moved();
155        if moved.is_empty() {
156            return;
157        }
158        let Some(cached) = self.cached_a11y.as_mut() else {
159            return;
160        };
161        let index: std::collections::HashMap<accesskit::NodeId, usize> = cached
162            .nodes
163            .iter()
164            .enumerate()
165            .map(|(i, (id, _))| (*id, i))
166            .collect();
167
168        for &id in moved.keys() {
169            let bounds = self.arena.bounds(id);
170            if let Some(&slot) = index.get(&crate::accessibility::widget_id_to_node_id(id)) {
171                cached.nodes[slot].1.set_bounds(to_accesskit_rect(bounds));
172            }
173        }
174
175        // Synthetic children ride their owner. Those that declared local
176        // bounds are re-derived from the owner's new origin — exact, and
177        // immune to a delta that accumulated across several passes. The
178        // rest already hold window-space rects (a scene item under a view
179        // transform) and are shifted by the owner's own delta.
180        for (&syn_id, &owner) in &self.synthetic_parent_map {
181            let Some(delta) = moved.get(&owner) else {
182                continue;
183            };
184            let Some(&slot) = index.get(&syn_id) else {
185                continue;
186            };
187            let node = &mut cached.nodes[slot].1;
188            if let Some(local) = self.synthetic_local_bounds.get(&syn_id) {
189                let origin = self.arena.bounds(owner).origin();
190                node.set_bounds(to_accesskit_rect(teksilo_canvas::Rect::new(
191                    origin.x + local.x,
192                    origin.y + local.y,
193                    local.width,
194                    local.height,
195                )));
196            } else if let Some(rect) = node.bounds() {
197                node.set_bounds(accesskit::Rect {
198                    x0: rect.x0 + delta.x as f64,
199                    y0: rect.y0 + delta.y as f64,
200                    x1: rect.x1 + delta.x as f64,
201                    y1: rect.y1 + delta.y as f64,
202                });
203            }
204        }
205    }
206
207    /// Build a `TreeUpdate` describing the tree right now, without touching any
208    /// state.
209    ///
210    /// For a caller that wants to *look* at the accessibility tree rather than
211    /// deliver it: an automation query, a screenshot's blind-spot check, a test
212    /// assertion. Unlike [`sync_accessibility`](Self::sync_accessibility) this
213    /// neither caches, nor bumps the AT version, nor records announcements, nor
214    /// advances the framework's live regions — which matters, because a caller
215    /// that consumed an announcer step and then dropped the update would have
216    /// silently eaten a message the user was meant to hear.
217    ///
218    /// It is a full walk every time; `sync_accessibility` is the one with the
219    /// cache.
220    pub fn accessibility_tree_snapshot(&self) -> accesskit::TreeUpdate {
221        self.build_accessibility_tree().0
222    }
223
224    /// Advance both announcers one step and report whether either still has
225    /// work. See [`crate::announcer`].
226    fn step_announcers(&mut self) -> bool {
227        // Both are stepped, not just the busy one: an announcer that is Idle
228        // and empty returns false and emits the same hidden node it emitted
229        // last time, so this costs nothing when nobody is announcing.
230        let polite = self.announcer_polite.step();
231        let assertive = self.announcer_assertive.step();
232        if polite || assertive {
233            self.a11y_dirty = true;
234        }
235        polite || assertive
236    }
237
238    /// Diff the live-region nodes of a freshly-built `TreeUpdate` against
239    /// the per-node last-announced-text map and push any changes into the
240    /// capped announcement ring buffer. See
241    /// [`crate::accessibility::Announcement`] and
242    /// [`Self::announcements_since`].
243    fn collect_announcements(&mut self, update: &accesskit::TreeUpdate) {
244        use accesskit::Live;
245        let mut seen: std::collections::HashSet<accesskit::NodeId> =
246            std::collections::HashSet::with_capacity(self.automation_last_text.len());
247        for (node_id, node) in &update.nodes {
248            let assertive = match node.live() {
249                Some(Live::Polite) => false,
250                Some(Live::Assertive) => true,
251                // `Live::Off` or unset: not a live region.
252                _ => continue,
253            };
254            // A live region announces its `value` if it has one, else its
255            // `label` (matching how the AT layer voices it).
256            let text = node
257                .value()
258                .or_else(|| node.label())
259                .unwrap_or("")
260                .trim()
261                .to_string();
262            if text.is_empty() {
263                // A cleared live region: forget its last text (and leave it out
264                // of `seen`, so `retain` drops it too) so the SAME text
265                // reappearing on a later sync is announced as novel rather than
266                // deduped against the stale entry.
267                self.automation_last_text.remove(node_id);
268                continue;
269            }
270            seen.insert(*node_id);
271            let changed = self
272                .automation_last_text
273                .get(node_id)
274                .map(|prev| prev != &text)
275                .unwrap_or(true);
276            if !changed {
277                continue;
278            }
279            self.automation_last_text.insert(*node_id, text.clone());
280            self.automation_announce_seq = self.automation_announce_seq.saturating_add(1);
281            if self.automation_announcements.len() >= super::AUTOMATION_ANNOUNCE_CAP {
282                self.automation_announcements.pop_front();
283            }
284            self.automation_announcements
285                .push_back(crate::accessibility::Announcement {
286                    seq: self.automation_announce_seq,
287                    text,
288                    assertive,
289                });
290        }
291        // Forget nodes that are no longer present (or no longer live), so a
292        // node that reappears with identical text re-announces.
293        self.automation_last_text.retain(|k, _| seen.contains(k));
294    }
295
296    /// Dispatch a synthetic AccessKit action to the node identified by
297    /// `node_id` (which may be a *synthetic*, widget-emitted child node —
298    /// e.g. a rich-text `TextRun`). Resolves the owning widget exactly the
299    /// way the platform AT adapter does
300    /// ([`node_id_to_widget_id_maybe`](crate::accessibility::node_id_to_widget_id_maybe)
301    /// then [`widget_for_synthetic`](Self::widget_for_synthetic)), builds a
302    /// [`crate::event::WidgetEvent::AccessAction`],
303    /// and dispatches it through `ops` so actions that open windows /
304    /// dialogs work.
305    ///
306    /// Returns `true` when the action was **consumed** — a handler claimed it,
307    /// focus moved, or the context-menu fallback opened a menu — and `false`
308    /// when nothing acted on it, including when the target node resolves to no
309    /// live widget. Callers are expected to surface that: an action a node
310    /// never handles is a caller error, and reporting it as success (which is
311    /// what "a widget existed at the target" amounted to) leaves an automation
312    /// client chasing timing and coordinates for a UI that was never going to
313    /// move.
314    ///
315    /// This is the in-process equivalent of an OS screen reader invoking an
316    /// action — the channel an [automation](crate::WidgetTree) harness uses
317    /// to *drive* the UI without the OS AT layer.
318    pub fn dispatch_access_action(
319        &mut self,
320        node_id: accesskit::NodeId,
321        action: accesskit::Action,
322        data: Option<accesskit::ActionData>,
323        ops: &mut dyn crate::window::WindowOps,
324    ) -> bool {
325        let target = crate::accessibility::node_id_to_widget_id_maybe(node_id)
326            .or_else(|| self.widget_for_synthetic(node_id));
327        let event = crate::event::WidgetEvent::AccessAction {
328            action,
329            target,
330            target_node: node_id,
331            data,
332        };
333        self.access_action_handled = false;
334        self.dispatch_event_with_ops(event, ops);
335        self.access_action_handled
336    }
337
338    // (helper `is_presentational_container` is a module-level free fn below)
339
340    /// Look up the owning widget for a synthetic AccessKit `NodeId`
341    /// emitted by `push_text_run_child` / `push_paragraph_child`.
342    /// Used by `handle_accessibility_actions` to route an
343    /// `ActionRequest` targeting a TextRun child back to the
344    /// editor that owns it.
345    pub fn widget_for_synthetic(&self, node_id: accesskit::NodeId) -> Option<WidgetId> {
346        self.synthetic_parent_map.get(&node_id).copied()
347    }
348
349    /// The text a tooltip would announce, harvested from its (dormant)
350    /// content widget so an anchor can carry it as a static AT description
351    /// while the tooltip is not shown.
352    ///
353    /// All three tiers publish their body through `accessibility()` as the
354    /// node *name* (`TooltipWidget::accessibility` → `set_name(text)`), so
355    /// probing the widget is enough — no parallel copy of the text has to be
356    /// threaded through `attach_tooltip*` and kept in sync. Reads at walk
357    /// time, so a locale change or a `Signal<String>` swap is picked up by the
358    /// same AT re-walk that already tracks them.
359    /// Resolve a tooltip's content node past a deferred host.
360    ///
361    /// A deferred tooltip body answers for itself while un-built (see
362    /// `DeferredSubtree::accessibility`), but once the user has hovered it the
363    /// host has handed its widget value to a real child and has nothing left to
364    /// say. Follow it, or a tooltip's description would be readable exactly
365    /// until the first time it was shown.
366    pub(crate) fn tooltip_content_node(&self, content_id: WidgetId) -> WidgetId {
367        self.arena
368            .get(content_id)
369            .and_then(|node| node.widget.as_any())
370            .and_then(|any| any.downcast_ref::<crate::deferred_subtree::DeferredSubtree>())
371            .and_then(|d| d.materialized_child())
372            .unwrap_or(content_id)
373    }
374
375    pub(crate) fn tooltip_access_description(&self, content_id: WidgetId) -> Option<String> {
376        let content_id = self.tooltip_content_node(content_id);
377        let node = self.arena.get(content_id)?;
378        let mut probe = AccessNodeBuilder::for_name_probe(content_id);
379        node.widget.accessibility(&mut probe);
380        if let Some(ov) = node.access_overrides.as_deref() {
381            ov.apply(&mut probe);
382        }
383        probe
384            .name()
385            .map(str::trim)
386            .filter(|name| !name.is_empty())
387            .map(str::to_owned)
388    }
389
390    pub fn accessibility_node(&self, id: WidgetId) -> AccessibilityInfo {
391        let node = self.arena.get(id).expect("widget id is active in arena");
392        let builder = self.build_overridden_builder(id);
393        let role = builder.role();
394        let name = builder.name().map(|s| s.to_string());
395        let actions = builder.actions().to_vec();
396        let mut info = AccessibilityInfo::new(role, name, actions);
397        if let Some(toggled) = builder.toggled() {
398            info = info.with_toggled(toggled);
399        }
400        if let Some(expanded) = builder.expanded() {
401            info = info.with_expanded(expanded);
402        }
403        if let Some(selected) = builder.selected() {
404            info = info.with_selected(selected);
405        }
406        // Mirror the framework gate at `build_accessibility_recursive`:
407        // arena-driven disabled wins unless the override explicitly
408        // asks for `access_disabled(false)`.
409        let force_clear_disabled =
410            node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(false);
411        let disabled_arena = !self.arena.is_enabled(id) && !force_clear_disabled;
412        // Override `Some(true)` already called `set_disabled()` inside
413        // the override apply; we only need to surface it here as a
414        // separate signal because `AccessNodeBuilder` doesn't expose a
415        // `is_disabled()` getter on the builder side.
416        let disabled_override =
417            node.access_overrides.as_deref().and_then(|ov| ov.disabled) == Some(true);
418        if disabled_arena || disabled_override {
419            info = info.with_disabled(true);
420        }
421        if builder.is_hidden() {
422            info = info.with_hidden(true);
423        }
424        info
425    }
426
427    pub fn find_by_role(&self, role: accesskit::Role) -> Option<WidgetId> {
428        self.arena
429            .active_ids_iter()
430            .find(|&id| self.build_overridden_builder(id).role() == role)
431    }
432
433    pub fn find_by_label(&self, label: &str) -> Option<WidgetId> {
434        self.arena
435            .active_ids_iter()
436            .find(|&id| self.build_overridden_builder(id).name() == Some(label))
437    }
438
439    pub fn find_by_action(&self, action: accesskit::Action) -> Option<WidgetId> {
440        self.arena.active_ids_iter().find(|&id| {
441            self.build_overridden_builder(id)
442                .actions()
443                .contains(&action)
444        })
445    }
446
447    /// Get the text content of a widget from its accessibility name.
448    /// Equivalent to the label set via `AccessNodeBuilder::set_name`,
449    /// after override application.
450    pub fn text_content(&self, id: WidgetId) -> Option<String> {
451        self.arena.get(id)?;
452        self.build_overridden_builder(id)
453            .name()
454            .map(|s| s.to_string())
455    }
456
457    /// Get the text value of a widget from its accessibility value.
458    /// Equivalent to the value set via `AccessNodeBuilder::set_value`,
459    /// after override application.
460    pub fn text_value(&self, id: WidgetId) -> Option<String> {
461        self.arena.get(id)?;
462        self.build_overridden_builder(id)
463            .value()
464            .map(|s| s.to_string())
465    }
466}
467
468/// Put the window's HiDPI scale on the root node, so every rectangle in the
469/// tree can stay logical.
470///
471/// AccessKit reads a node's `bounds` in the coordinate space of the nearest
472/// ancestor carrying a `transform`, and expects the *transformed* result to be
473/// in physical pixels relative to the window origin. Teksilo lays out, paints,
474/// hit-tests and drives automation in logical pixels throughout, so every
475/// emitter — this crate's walker, `teksilo-charts`' marks, `teksilo-scene`'s
476/// items and magnets — writes a logical rectangle and none of them knows the
477/// display scale.
478///
479/// One transform on the root reconciles the two. `accesskit_consumer`
480/// accumulates a node's transform with every ancestor's up to the root, so a
481/// single scale here reaches every descendant, **including synthetic children**
482/// (text runs, chart marks, scene items) that hang off a widget with no
483/// transform of its own. It also scales the per-character positions and widths
484/// a text run carries, which live in the same node space and which a
485/// per-rectangle fix-up would silently miss.
486///
487/// The corollary is a rule for every emitter: **never multiply an emitted
488/// rectangle by a scale factor**. Doing so double-scales it here. The walker's
489/// own conformance to that rule is pinned by
490/// `emitted_bounds_stay_logical_at_scale_two`.
491///
492/// The translation term in `accesskit_winit`'s own example is an iOS-only
493/// outer-versus-inner window offset and is zero on every desktop platform;
494/// Teksilo's [`WidgetTree::safe_area`] is a *content* inset and is emphatically
495/// not the same quantity, so it does not belong here.
496pub(crate) fn emit_root_transform(root: &mut accesskit::Node, device_scale_factor: f32) {
497    root.set_transform(accesskit::Affine::scale(device_scale_factor as f64));
498}
499
500#[cfg(test)]
501pub(crate) mod test_helpers {
502    /// Feed a `TreeUpdate` into `accesskit_consumer::Tree`, which runs the
503    /// same validation that every platform AT (VoiceOver, NVDA, …) runs on
504    /// activation. Panics on duplicate children, dangling relationship
505    /// targets, orphaned nodes, and invalid focus — turning those runtime
506    /// crashes into CI failures.
507    pub(crate) fn assert_a11y_tree_valid(update: &accesskit::TreeUpdate) {
508        accesskit_consumer::Tree::new(update.clone(), false);
509    }
510
511    /// Assert that every NodeId referenced by a relation of any node is
512    /// present in the tree. This is the invariant our post-processing pass
513    /// enforces; having a test here means a future refactor can't silently
514    /// drop the pass and regress it.
515    ///
516    /// `next_on_line` / `previous_on_line` are checked alongside the
517    /// relation lists: a text run linked to a run that never reached the
518    /// tree hangs `accesskit_consumer`'s line walk instead of ending it.
519    pub(crate) fn assert_no_dangling_relationships(update: &accesskit::TreeUpdate) {
520        let emitted: std::collections::HashSet<accesskit::NodeId> =
521            update.nodes.iter().map(|(id, _)| *id).collect();
522        for (parent_id, node) in &update.nodes {
523            for &target in node.controls() {
524                assert!(
525                    emitted.contains(&target),
526                    "node {parent_id:?} has controls() → {target:?} which is absent from the tree"
527                );
528            }
529            for &target in node.described_by() {
530                assert!(
531                    emitted.contains(&target),
532                    "node {parent_id:?} has described_by() → {target:?} which is absent from the tree"
533                );
534            }
535            for &target in node.labelled_by() {
536                assert!(
537                    emitted.contains(&target),
538                    "node {parent_id:?} has labelled_by() → {target:?} which is absent from the tree"
539                );
540            }
541            if let Some(target) = node.next_on_line() {
542                assert!(
543                    emitted.contains(&target),
544                    "node {parent_id:?} has next_on_line() → {target:?} which is absent from the tree"
545                );
546            }
547            if let Some(target) = node.previous_on_line() {
548                assert!(
549                    emitted.contains(&target),
550                    "node {parent_id:?} has previous_on_line() → {target:?} which is absent from the tree"
551                );
552            }
553        }
554    }
555
556    /// Return all NodeIds whose role matches `role`.
557    #[allow(dead_code)]
558    pub(crate) fn nodes_with_role(
559        update: &accesskit::TreeUpdate,
560        role: accesskit::Role,
561    ) -> Vec<accesskit::NodeId> {
562        update
563            .nodes
564            .iter()
565            .filter(|(_, node)| node.role() == role)
566            .map(|(id, _)| *id)
567            .collect()
568    }
569}
570
571#[cfg(test)]
572mod tests {
573    use super::test_helpers::*;
574    use super::*;
575    use crate::test_widgets::{FillWidget, StackWidget};
576
577    /// The two framework-owned live-region nodes every `TreeUpdate` carries.
578    /// Named rather than inlined so a node-count assertion says what it is
579    /// counting. See [`crate::announcer`].
580    const ANNOUNCER_NODES: usize = 2;
581
582    // The framework's announcer.
583    //
584    // These assert through `accesskit_consumer`, not only through the tree's
585    // own announcement ring. The ring reads `value().or(label())` while every
586    // platform adapter reads `label()` alone, so a live region can pass the
587    // in-process check and be silent on all three platforms — which is exactly
588    // how two of this framework's own live regions shipped mute. Whether the
589    // node is *in the filtered tree* is what the adapters key on, so that is
590    // what these check.
591
592    /// Is the announcer node one an adapter would see, and what does it say?
593    ///
594    /// `accesskit_consumer::Tree` runs the same filter each platform adapter
595    /// runs. A hidden node is `FilterResult::ExcludeSubtree`, so it is absent
596    /// from the filtered walk and no adapter will announce it.
597    fn announced_by_consumer(update: &accesskit::TreeUpdate) -> Vec<(String, accesskit::Live)> {
598        let consumer = accesskit_consumer::Tree::new(update.clone(), false);
599        let state = consumer.state();
600        let mut found = Vec::new();
601        let mut stack = vec![state.root()];
602        while let Some(node) = stack.pop() {
603            if node.live() != accesskit::Live::Off
604                && let Some(label) = node.label()
605            {
606                found.push((label, node.live()));
607            }
608            for child in node.filtered_children(&accesskit_consumer::common_filter) {
609                stack.push(child);
610            }
611        }
612        found
613    }
614
615    /// Every live node an adapter can reach in the filtered walk, by id.
616    ///
617    /// Distinct from [`announced_by_consumer`] on purpose: a node that stays in
618    /// the tree with its label cleared looks "silent" to a label-based check
619    /// while remaining present. Present-but-unlabelled is exactly what does NOT
620    /// work — on Linux the announcement is emitted from `add_node` alone, so
621    /// only a node that genuinely leaves and re-enters the filtered tree
622    /// announces twice.
623    fn live_nodes_in_filtered_tree(update: &accesskit::TreeUpdate) -> Vec<String> {
624        let consumer = accesskit_consumer::Tree::new(update.clone(), false);
625        let state = consumer.state();
626        let mut found = Vec::new();
627        let mut stack = vec![state.root()];
628        while let Some(node) = stack.pop() {
629            if node.live() != accesskit::Live::Off {
630                found.push(format!("{:?}", node.id()));
631            }
632            for child in node.filtered_children(&accesskit_consumer::common_filter) {
633                stack.push(child);
634            }
635        }
636        found.sort();
637        found
638    }
639
640    fn tree_with_one_widget() -> WidgetTree {
641        let mut tree = WidgetTree::new();
642        tree.add(FillWidget::new().label("content"));
643        tree.layout(SizeProposal::exact(200.0, 100.0));
644        tree
645    }
646
647    /// The headline behaviour: `announce` reaches the filtered tree exactly
648    /// once, then leaves it again.
649    #[test]
650    fn an_announcement_enters_the_filtered_tree_then_leaves_it() {
651        let mut tree = tree_with_one_widget();
652        // Nothing announced yet: both live regions are hidden, so a platform
653        // adapter sees neither.
654        let update = tree.sync_accessibility();
655        assert_eq!(announced_by_consumer(&update), Vec::new());
656        assert_eq!(
657            live_nodes_in_filtered_tree(&update),
658            Vec::<String>::new(),
659            "an idle announcer must be outside the filtered tree"
660        );
661        assert_a11y_tree_valid(&update);
662
663        tree.announce("Event added");
664
665        let exposed = tree.sync_accessibility();
666        assert_eq!(
667            announced_by_consumer(&exposed),
668            vec![("Event added".to_string(), accesskit::Live::Polite)],
669            "the message must be a label on a live node inside the filtered tree"
670        );
671        assert_eq!(
672            live_nodes_in_filtered_tree(&exposed).len(),
673            1,
674            "exactly the polite announcer must have entered the filtered tree"
675        );
676        assert_a11y_tree_valid(&exposed);
677
678        let retracted = tree.sync_accessibility();
679        assert_eq!(
680            announced_by_consumer(&retracted),
681            Vec::new(),
682            "the node must stop carrying the message"
683        );
684        // The load-bearing half. Clearing the label would satisfy the check
685        // above while leaving the node in the tree, and a node that never
686        // leaves the tree never announces again on Linux: the AT-SPI adapter
687        // emits `ObjectEvent::Announcement` from `add_node` and from nowhere
688        // else. So assert the node is genuinely gone from the filtered walk.
689        assert_eq!(
690            live_nodes_in_filtered_tree(&retracted),
691            Vec::<String>::new(),
692            "the live node must leave the filtered tree entirely, not merely \
693             lose its label"
694        );
695        assert_a11y_tree_valid(&retracted);
696    }
697
698    /// The case the retract exists for. Both the Windows and the macOS adapter
699    /// only announce an update whose label *changed*, so the same string twice
700    /// in a row would be spoken once. Leaving and re-entering the tree is what
701    /// makes the second one a fresh arrival.
702    #[test]
703    fn the_same_message_announced_twice_is_exposed_twice() {
704        let mut tree = tree_with_one_widget();
705        tree.announce("Saved");
706        tree.announce("Saved");
707
708        let mut exposures = 0;
709        for _ in 0..6 {
710            let update = tree.sync_accessibility();
711            if announced_by_consumer(&update)
712                .iter()
713                .any(|(text, _)| text == "Saved")
714            {
715                exposures += 1;
716            }
717        }
718        assert_eq!(exposures, 2, "each announcement needs its own exposure");
719    }
720
721    #[test]
722    fn an_assertive_announcement_uses_the_assertive_live_setting() {
723        let mut tree = tree_with_one_widget();
724        tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
725        let update = tree.sync_accessibility();
726        assert_eq!(
727            announced_by_consumer(&update),
728            vec![("Could not save".to_string(), accesskit::Live::Assertive)]
729        );
730    }
731
732    /// The two levels are independent nodes, so an urgent message does not have
733    /// to wait behind a polite one.
734    #[test]
735    fn the_two_politeness_levels_announce_independently() {
736        let mut tree = tree_with_one_widget();
737        tree.announce("Event added");
738        tree.announce_with("Could not save", crate::announcer::Politeness::Assertive);
739
740        let mut spoken = announced_by_consumer(&tree.sync_accessibility());
741        spoken.sort_by(|a, b| a.0.cmp(&b.0));
742        assert_eq!(
743            spoken,
744            vec![
745                ("Could not save".to_string(), accesskit::Live::Assertive),
746                ("Event added".to_string(), accesskit::Live::Polite),
747            ],
748            "both levels must be exposed on the same update"
749        );
750    }
751
752    /// Announcing must wake a tree that has nothing else to redraw, or a
753    /// message raised by a handler that changed nothing visible would sit
754    /// unspoken until something unrelated happened.
755    #[test]
756    fn announcing_requests_the_syncs_it_needs() {
757        let mut tree = tree_with_one_widget();
758        let _ = tree.sync_accessibility();
759        tree.a11y_update_requested.set(false);
760        tree.frame_tick_requested.set(false);
761
762        tree.announce("Event added");
763        assert!(
764            tree.a11y_update_requested.get(),
765            "an announcement must dirty the accessibility tree"
766        );
767        assert!(
768            tree.frame_requested(),
769            "an announcement must ask for the frame that carries it"
770        );
771
772        // Exposing leaves a retract still to do, so it asks again.
773        tree.a11y_update_requested.set(false);
774        tree.frame_tick_requested.set(false);
775        let _ = tree.sync_accessibility();
776        assert!(tree.a11y_update_requested.get());
777        assert!(tree.frame_requested());
778
779        // After the retract there is nothing more to schedule.
780        tree.a11y_update_requested.set(false);
781        tree.frame_tick_requested.set(false);
782        let _ = tree.sync_accessibility();
783        assert!(!tree.a11y_update_requested.get());
784        assert!(!tree.frame_requested());
785    }
786
787    /// The in-process ring is what the automation bridge reports, so it has to
788    /// see the same thing the adapters do.
789    #[test]
790    fn an_announcement_reaches_the_automation_ring() {
791        let mut tree = tree_with_one_widget();
792        let before = tree.announcements_since(0).len() as u64;
793        tree.announce("Event added");
794        let _ = tree.sync_accessibility();
795        let got = tree.announcements_since(before);
796        assert_eq!(got.len(), 1);
797        assert_eq!(got[0].text, "Event added");
798        assert!(!got[0].assertive);
799    }
800
801    /// A screenshot inspects the tree and throws the update away. If it went
802    /// through `sync_accessibility` it would consume the exposure step and the
803    /// user would never hear the message.
804    #[test]
805    fn a_tree_snapshot_does_not_consume_a_pending_announcement() {
806        let mut tree = tree_with_one_widget();
807        tree.announce("Event added");
808
809        let snapshot = tree.accessibility_tree_snapshot();
810        assert_eq!(
811            announced_by_consumer(&snapshot),
812            Vec::new(),
813            "a snapshot must not advance the announcer"
814        );
815
816        let delivered = tree.sync_accessibility();
817        assert_eq!(
818            announced_by_consumer(&delivered),
819            vec![("Event added".to_string(), accesskit::Live::Polite)],
820            "the message must still be there to deliver"
821        );
822    }
823
824    /// An empty message is dropped rather than costing an exposure that says
825    /// nothing.
826    #[test]
827    fn an_empty_announcement_is_not_exposed() {
828        let mut tree = tree_with_one_widget();
829        let _ = tree.sync_accessibility();
830        tree.announce("   ");
831        let update = tree.sync_accessibility();
832        assert_eq!(announced_by_consumer(&update), Vec::new());
833    }
834
835    #[derive(Debug)]
836    struct ActionWidget;
837
838    impl Widget for ActionWidget {
839        fn layout_response(
840            &self,
841            proposal: SizeProposal,
842            _ctx: &LayoutContext,
843        ) -> crate::widget::LayoutResponse {
844            proposal.resolve(0.0, 0.0).into()
845        }
846
847        fn accessibility(&self, builder: &mut AccessNodeBuilder) {
848            builder.set_role(accesskit::Role::Button);
849            builder.set_name("Save");
850            builder.add_action(accesskit::Action::Click);
851            builder.add_action(accesskit::Action::Focus);
852        }
853    }
854
855    #[derive(Debug)]
856    struct ClickableWidget;
857
858    impl Widget for ClickableWidget {
859        fn layout_response(
860            &self,
861            proposal: SizeProposal,
862            _ctx: &LayoutContext,
863        ) -> crate::widget::LayoutResponse {
864            proposal.resolve(0.0, 0.0).into()
865        }
866
867        fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
868            builder.set_role(accesskit::Role::Button);
869            builder.set_name("Click Me");
870            builder.add_action(accesskit::Action::Click);
871        }
872    }
873
874    /// A focusable node advertises `Action::Focus` whether or not its widget
875    /// remembered to. The dispatcher services the action for any focusable
876    /// node, so deriving the advertisement from the arena flag is what keeps
877    /// the two in step — see `announce_focusable`.
878    #[test]
879    fn a_focusable_node_advertises_focus_without_the_widget_saying_so() {
880        let mut tree = WidgetTree::new();
881        // `ClickableWidget` names Click and nothing else.
882        let id = tree.add(ClickableWidget.focusable(true));
883        tree.layout(SizeProposal::exact(100.0, 100.0));
884
885        let info = tree.accessibility_node(id);
886        assert!(
887            info.actions().contains(&accesskit::Action::Focus),
888            "the arena knows the node is focusable; the AT node must say so"
889        );
890
891        // Advertised means serviceable, not merely announced.
892        let mut ops = crate::window::NoopWindowOps;
893        assert!(tree.dispatch_access_action(
894            crate::accessibility::widget_id_to_node_id(id),
895            accesskit::Action::Focus,
896            None,
897            &mut ops,
898        ));
899        assert_eq!(tree.focused(), Some(id));
900    }
901
902    /// A composite that is not itself focusable but publishes one AT node and
903    /// keeps its keyboard focus on an inner leaf — the shape `SpinBox`,
904    /// `ComboBox` and `DateEdit` all have. What marks it as one is that it
905    /// advertises `Action::Focus` from its own `accessibility()`, which is
906    /// exactly what those three do.
907    #[derive(Debug)]
908    struct CompositeWidget {
909        child_ids: Vec<WidgetId>,
910    }
911
912    impl Widget for CompositeWidget {
913        fn layout_response(
914            &self,
915            proposal: SizeProposal,
916            _ctx: &LayoutContext,
917        ) -> crate::widget::LayoutResponse {
918            proposal.resolve(0.0, 0.0).into()
919        }
920
921        fn place_children(
922            &self,
923            bounds: Rect,
924            _proposal: SizeProposal,
925            children: &mut [crate::widget::WidgetPlacement],
926            _ctx: &LayoutContext,
927        ) {
928            for child in children.iter_mut() {
929                child.origin = bounds.origin();
930                child.size = bounds.size();
931            }
932        }
933
934        fn children(&self) -> Vec<WidgetId> {
935            self.child_ids.clone()
936        }
937
938        fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
939            builder.set_role(accesskit::Role::SpinButton);
940            builder.add_action(accesskit::Action::Focus);
941        }
942    }
943
944    /// An assistive-tech `Focus` on a composite lands on the node that takes
945    /// the keys, not on the root that publishes the AT node.
946    ///
947    /// `ctx.request_focus` has always walked to the first focusable descendant,
948    /// precisely because a composite like `TextInput` or `SpinBox` keeps its
949    /// focus on an inner leaf. The AT path did not, so a screen reader — or the
950    /// automation `focus_node` tool — parked `self.focused` on a root that
951    /// accepts no keystrokes. The second assertion is the one that bites:
952    /// `on_key_preview` fires only on *strict* ancestors of the focused node, so
953    /// a composite focused on its own root also loses its own stepping keys.
954    #[test]
955    fn an_at_focus_on_a_non_focusable_composite_lands_on_its_focusable_leaf() {
956        let mut tree = WidgetTree::new();
957        let leaf = tree.add(ClickableWidget.focusable(true));
958        let root = tree.add(CompositeWidget {
959            child_ids: vec![leaf],
960        });
961        tree.layout(SizeProposal::exact(100.0, 100.0));
962
963        let mut ops = crate::window::NoopWindowOps;
964        assert!(tree.dispatch_access_action(
965            crate::accessibility::widget_id_to_node_id(root),
966            accesskit::Action::Focus,
967            None,
968            &mut ops,
969        ));
970        assert_eq!(
971            tree.focused(),
972            Some(leaf),
973            "focus must reach the focusable leaf, not the composite root"
974        );
975    }
976
977    /// …and only a composite gets that walk.
978    ///
979    /// A plain container — a `Panel`, a `GroupBox`, a landmark, a label — is
980    /// not focusable and offers no `Focus`, so an assistive technology asking
981    /// to focus it is asking for something the node never advertised. Walking
982    /// in anyway moved the keyboard onto the first control inside it, which the
983    /// technology could have named itself and did not, and then reported
984    /// success. The honest answer is that the action was not handled.
985    #[test]
986    fn an_at_focus_on_a_plain_container_does_not_walk_into_it() {
987        let mut tree = WidgetTree::new();
988        let leaf = tree.add(ClickableWidget.focusable(true));
989        let root = tree.add(StackWidget::new().add_child(leaf));
990        tree.layout(SizeProposal::exact(100.0, 100.0));
991
992        let mut ops = crate::window::NoopWindowOps;
993        assert!(
994            !tree.dispatch_access_action(
995                crate::accessibility::widget_id_to_node_id(root),
996                accesskit::Action::Focus,
997                None,
998                &mut ops,
999            ),
1000            "a node that never advertised `Focus` must report the action unhandled"
1001        );
1002        assert_eq!(
1003            tree.focused(),
1004            None,
1005            "focus must stay where it was, not fall into the container's first control"
1006        );
1007    }
1008
1009    /// The walk is a fallback, not a redirect: a focusable node still focuses
1010    /// itself, so every leaf control is untouched by the composite fix.
1011    #[test]
1012    fn an_at_focus_on_a_focusable_node_still_lands_on_that_node() {
1013        let mut tree = WidgetTree::new();
1014        let outer = tree.add(ClickableWidget.focusable(true));
1015        tree.layout(SizeProposal::exact(100.0, 100.0));
1016
1017        let mut ops = crate::window::NoopWindowOps;
1018        assert!(tree.dispatch_access_action(
1019            crate::accessibility::widget_id_to_node_id(outer),
1020            accesskit::Action::Focus,
1021            None,
1022            &mut ops,
1023        ));
1024        assert_eq!(tree.focused(), Some(outer));
1025    }
1026
1027    /// The derivation is a default, not a decree: `access_remove_action` still
1028    /// takes it away, because it is applied before the overrides.
1029    #[test]
1030    fn a_derived_focus_action_is_still_removable_by_an_override() {
1031        let mut tree = WidgetTree::new();
1032        let id = tree.add(
1033            ClickableWidget
1034                .focusable(true)
1035                .access_remove_action(accesskit::Action::Focus),
1036        );
1037        tree.layout(SizeProposal::exact(100.0, 100.0));
1038        assert!(
1039            !tree
1040                .accessibility_node(id)
1041                .actions()
1042                .contains(&accesskit::Action::Focus)
1043        );
1044    }
1045
1046    /// A node nobody can focus must not advertise focus — it would be an
1047    /// action that declines to land.
1048    #[test]
1049    fn a_non_focusable_node_does_not_advertise_focus() {
1050        let mut tree = WidgetTree::new();
1051        let id = tree.add(ClickableWidget);
1052        tree.layout(SizeProposal::exact(100.0, 100.0));
1053        assert!(
1054            !tree
1055                .accessibility_node(id)
1056                .actions()
1057                .contains(&accesskit::Action::Focus)
1058        );
1059    }
1060
1061    #[test]
1062    fn labeled_widget_has_accessibility() {
1063        let mut tree = WidgetTree::new();
1064        let widget = tree.add(FillWidget::new().label("Hello"));
1065        tree.layout(SizeProposal::exact(100.0, 20.0));
1066        let info = tree.accessibility_node(widget);
1067        assert_eq!(info.role(), accesskit::Role::Label);
1068        assert_eq!(info.name(), Some("Hello"));
1069    }
1070
1071    #[test]
1072    fn find_by_label_works() {
1073        let mut tree = WidgetTree::new();
1074        let widget = tree.add(FillWidget::new().label("Save"));
1075        tree.layout(SizeProposal::exact(100.0, 20.0));
1076        assert_eq!(tree.find_by_label("Save"), Some(widget));
1077    }
1078
1079    #[test]
1080    fn label_role_emits_text_as_value_not_label() {
1081        // accesskit contract: a `Role::Label` node carries its text in the
1082        // `value` property, not `label`. Widgets set the accessible name via
1083        // `set_name` (-> `label`); the builder re-serializes it to `value`
1084        // when the role is `Role::Label`, otherwise the Name is empty under
1085        // Windows UIA and stray under macOS AXStaticText. The logical
1086        // introspection name (used by `find_by_label` etc.) is unchanged.
1087        let mut tree = WidgetTree::new();
1088        let widget = tree.add(FillWidget::new().label("Section")); // Role::Label
1089        tree.layout(SizeProposal::exact(100.0, 20.0));
1090
1091        // Logical view still reports the accessible name.
1092        assert_eq!(tree.accessibility_node(widget).name(), Some("Section"));
1093
1094        // The *emitted* node carries the text in `value`, with `label` cleared.
1095        let update = tree.sync_accessibility();
1096        let node = &update
1097            .nodes
1098            .iter()
1099            .find(|(nid, _)| *nid == crate::accessibility::widget_id_to_node_id(widget))
1100            .expect("label node must be present in the tree update")
1101            .1;
1102        assert_eq!(node.role(), accesskit::Role::Label);
1103        assert_eq!(
1104            node.value(),
1105            Some("Section"),
1106            "Role::Label text must be exposed via `value`"
1107        );
1108        assert_eq!(
1109            node.label(),
1110            None,
1111            "Role::Label must not leave text in the `label` property"
1112        );
1113    }
1114
1115    #[test]
1116    fn find_by_role_works() {
1117        let mut tree = WidgetTree::new();
1118        tree.add(FillWidget::new().label("Text"));
1119        tree.layout(SizeProposal::exact(100.0, 20.0));
1120        assert!(tree.find_by_role(accesskit::Role::Label).is_some());
1121    }
1122
1123    #[test]
1124    fn access_hidden_bound_to_signal_toggles_reactively() {
1125        use crate::signal::Signal;
1126        use crate::widget_builder::WidgetBuilder;
1127        let hidden = Signal::new(false);
1128        let mut tree = WidgetTree::new();
1129        let w = tree.add(ClickableWidget.access_hidden(hidden.clone()));
1130        tree.layout(SizeProposal::exact(100.0, 30.0));
1131        assert!(
1132            !tree.accessibility_node(w).is_hidden(),
1133            "node should be visible to AT while the signal is false"
1134        );
1135        hidden.set(true);
1136        // `apply()` reads the prop fresh, so the pulled node reflects the flip.
1137        assert!(
1138            tree.accessibility_node(w).is_hidden(),
1139            "node should hide from AT when the bound signal flips to true"
1140        );
1141        hidden.set(false);
1142        assert!(!tree.accessibility_node(w).is_hidden());
1143    }
1144
1145    /// **A widget with a context menu says so.** The dispatcher already opened
1146    /// the menu for `Action::ShowContextMenu`; nothing advertised the action, and
1147    /// an assistive technology offers what a node advertises.
1148    #[test]
1149    fn a_widget_with_a_context_menu_advertises_the_action() {
1150        let mut tree = WidgetTree::new();
1151        let plain = tree.add(FillWidget::new().label("no menu"));
1152        let with_menu = tree.add(
1153            FillWidget::new()
1154                .label("has menu")
1155                .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1156        );
1157        tree.layout(SizeProposal::exact(100.0, 20.0));
1158
1159        assert!(
1160            tree.accessibility_node(with_menu)
1161                .actions()
1162                .contains(&Action::ShowContextMenu),
1163            "a node owning a context-menu factory must announce it"
1164        );
1165        assert!(
1166            !tree
1167                .accessibility_node(plain)
1168                .actions()
1169                .contains(&Action::ShowContextMenu),
1170            "and a node without one must not"
1171        );
1172    }
1173
1174    /// The action is derived BEFORE the overrides, so an author who explicitly
1175    /// takes it away still has it taken away.
1176    #[test]
1177    fn a_removed_context_menu_action_stays_removed() {
1178        let mut tree = WidgetTree::new();
1179        let id = tree.add(
1180            FillWidget::new()
1181                .label("suppressed")
1182                .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>))
1183                .access_remove_action(Action::ShowContextMenu),
1184        );
1185        tree.layout(SizeProposal::exact(100.0, 20.0));
1186        assert!(
1187            !tree
1188                .accessibility_node(id)
1189                .actions()
1190                .contains(&Action::ShowContextMenu)
1191        );
1192    }
1193
1194    /// A descendant of a widget with a menu does **not** advertise it, even
1195    /// though the ancestor walk would open one there. Otherwise every nested box
1196    /// under a row with a menu would offer that menu, and an AT would read a
1197    /// dozen of them.
1198    #[test]
1199    fn a_child_does_not_advertise_its_parents_context_menu() {
1200        let mut tree = WidgetTree::new();
1201        let child = tree.add(FillWidget::new().label("inner"));
1202        let parent = tree.add(
1203            StackWidget::new()
1204                .add_child(child)
1205                .context_menu(|_pos, _ctx| Some(Box::new(FillWidget::new()) as Box<dyn Widget>)),
1206        );
1207        tree.layout(SizeProposal::exact(100.0, 20.0));
1208
1209        assert!(
1210            tree.accessibility_node(parent)
1211                .actions()
1212                .contains(&Action::ShowContextMenu)
1213        );
1214        assert!(
1215            !tree
1216                .accessibility_node(child)
1217                .actions()
1218                .contains(&Action::ShowContextMenu),
1219            "the menu belongs to the widget that declared it"
1220        );
1221    }
1222
1223    #[test]
1224    fn accessibility_node_collects_actions() {
1225        let mut tree = WidgetTree::new();
1226        let widget = tree.add(ActionWidget);
1227        tree.layout(SizeProposal::exact(100.0, 40.0));
1228
1229        let info = tree.accessibility_node(widget);
1230        assert_eq!(info.role(), accesskit::Role::Button);
1231        assert_eq!(info.name(), Some("Save"));
1232        assert_eq!(info.actions().len(), 2);
1233        assert!(info.actions().contains(&accesskit::Action::Click));
1234        assert!(info.actions().contains(&accesskit::Action::Focus));
1235    }
1236
1237    #[test]
1238    fn sync_accessibility_produces_tree_update() {
1239        let mut tree = WidgetTree::new();
1240        tree.add(FillWidget::new().label("First"));
1241        tree.add(FillWidget::new().label("Second"));
1242        tree.layout(SizeProposal::exact(200.0, 100.0));
1243
1244        let update = tree.sync_accessibility();
1245        // Root, the two widgets, and the two framework live regions, which are
1246        // always present (and, here, hidden). See `crate::announcer`.
1247        assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1248        assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1249        assert!(update.tree.is_some());
1250        assert_a11y_tree_valid(&update);
1251    }
1252
1253    #[test]
1254    fn root_node_carries_locale_as_language() {
1255        let mut tree = WidgetTree::new();
1256        tree.add(FillWidget::new().label("Bonjour"));
1257
1258        // No locale set yet → no language hint (VoiceOver uses its default).
1259        tree.layout(SizeProposal::exact(200.0, 100.0));
1260        let update = tree.sync_accessibility();
1261        assert_eq!(update.nodes[0].0, accesskit::NodeId(0));
1262        assert_eq!(
1263            update.nodes[0].1.language(),
1264            None,
1265            "no language before a locale is set"
1266        );
1267
1268        // Once the app sets the locale, the root Window node carries it as a
1269        // BCP-47 language tag, which AccessKit propagates to the whole subtree.
1270        tree.set_locale("fr-FR".to_string());
1271        let update = tree.sync_accessibility();
1272        assert_eq!(
1273            update.nodes[0].1.language(),
1274            Some("fr-FR"),
1275            "root node must advertise the active locale as its language"
1276        );
1277    }
1278
1279    #[test]
1280    fn sync_accessibility_excludes_dormant_widgets() {
1281        let mut tree = WidgetTree::new();
1282        tree.add(FillWidget::new().label("Active"));
1283        let dormant = tree.add(FillWidget::new().label("Dormant"));
1284        tree.layout(SizeProposal::exact(200.0, 100.0));
1285
1286        tree.set_dormant(dormant);
1287
1288        let update = tree.sync_accessibility();
1289        assert_eq!(update.nodes.len(), 2 + ANNOUNCER_NODES);
1290        assert_a11y_tree_valid(&update);
1291    }
1292
1293    /// Regression test: a Relayout-only signal flip (no activation
1294    /// change, no role/label/value change, no focus change, no overlay
1295    /// activation) must NOT dirty the AccessKit cache. Previously
1296    /// `layout()` set `a11y_dirty = true` unconditionally on every
1297    /// layout pass, which fired ~60 Hz on any scene with a Pulse /
1298    /// Cycle animation.
1299    #[test]
1300    fn relayout_without_activation_change_does_not_dirty_a11y() {
1301        let mut tree = WidgetTree::new();
1302        let id = tree.add(FillWidget::new().label("Static"));
1303        tree.layout(SizeProposal::exact(200.0, 100.0));
1304
1305        // First sync clears `a11y_dirty` and populates the cache.
1306        let _ = tree.sync_accessibility();
1307        assert!(
1308            !tree.a11y_dirty,
1309            "sync_accessibility must clear the dirty flag"
1310        );
1311
1312        // Simulate a Relayout-binding flip: the widget needs
1313        // re-layout, but its accessibility shape (active set, focus,
1314        // role, label, value) is unchanged.
1315        tree.arena.mark_needs_layout(id);
1316        tree.layout(SizeProposal::exact(200.0, 100.0));
1317
1318        assert!(
1319            !tree.a11y_dirty,
1320            "pure Relayout (no activation / focus / overlay / a11y-binding change) must not dirty the AT cache"
1321        );
1322    }
1323
1324    /// A container that places its single child at a settable offset,
1325    /// without changing its size — the shape of a scroll.
1326    #[derive(Debug)]
1327    struct Mover {
1328        offset: crate::signal::Signal<f32>,
1329        child: WidgetId,
1330    }
1331
1332    impl crate::widget::Widget for Mover {
1333        fn children(&self) -> Vec<WidgetId> {
1334            vec![self.child]
1335        }
1336        fn layout_response(
1337            &self,
1338            proposal: SizeProposal,
1339            _ctx: &crate::widget::LayoutContext,
1340        ) -> crate::widget::LayoutResponse {
1341            proposal.resolve(200.0, 100.0).into()
1342        }
1343        fn place_children(
1344            &self,
1345            bounds: teksilo_canvas::Rect,
1346            _proposal: SizeProposal,
1347            children: &mut [crate::widget::WidgetPlacement],
1348            _ctx: &crate::widget::LayoutContext,
1349        ) {
1350            if let Some(placement) = children.first_mut() {
1351                placement.origin =
1352                    teksilo_canvas::Point::new(bounds.x + self.offset.get(), bounds.y);
1353                placement.size = teksilo_canvas::Size::new(50.0, 20.0);
1354            }
1355        }
1356    }
1357
1358    /// A widget that only moved is re-placed in the cached tree.
1359    ///
1360    /// This is the scroll case, and the reason the AT tree's bounds used to
1361    /// go stale: re-walking on every scroll frame was too expensive to do,
1362    /// so it was not done, and every node in a scrolled view reported the
1363    /// position it had before the scroll.
1364    #[test]
1365    fn translating_a_widget_patches_the_cache_without_a_walk() {
1366        let mut tree = WidgetTree::new();
1367        let inner = tree.add(FillWidget::new().label("Row"));
1368        let offset = crate::signal::Signal::new(0.0f32);
1369        let root = tree.add(Mover {
1370            offset: offset.clone(),
1371            child: inner,
1372        });
1373        tree.layout(SizeProposal::exact(200.0, 100.0));
1374        let update = tree.sync_accessibility();
1375        let walks = tree.a11y_walk_generation();
1376        let before = find_node(&update, inner)
1377            .unwrap()
1378            .bounds()
1379            .expect("the node carries bounds");
1380
1381        // Move the row without resizing it.
1382        offset.set(20.0);
1383        tree.arena.mark_needs_layout(root);
1384        tree.layout(SizeProposal::exact(200.0, 100.0));
1385
1386        let update = tree.sync_accessibility();
1387        assert_eq!(
1388            tree.a11y_walk_generation(),
1389            walks,
1390            "a pure translation must not cost a walk"
1391        );
1392        let after = find_node(&update, inner)
1393            .unwrap()
1394            .bounds()
1395            .expect("the node still carries bounds");
1396        assert!(
1397            (after.x0 - before.x0 - 20.0).abs() < 0.01,
1398            "the cached node must follow the move: {before:?} -> {after:?}"
1399        );
1400    }
1401
1402    #[test]
1403    fn at_version_does_not_bump_for_a_move() {
1404        // `at_version`'s contract is semantic change. A widget that moved
1405        // says the same thing from somewhere else, and a harness waiting on
1406        // the version must not wake for it.
1407        let mut tree = WidgetTree::new();
1408        let inner = tree.add(FillWidget::new().label("Row"));
1409        let offset = crate::signal::Signal::new(0.0f32);
1410        let root = tree.add(Mover {
1411            offset: offset.clone(),
1412            child: inner,
1413        });
1414        tree.layout(SizeProposal::exact(200.0, 100.0));
1415        let _ = tree.sync_accessibility();
1416        let version = tree.at_version().get();
1417
1418        offset.set(20.0);
1419        tree.arena.mark_needs_layout(root);
1420        tree.layout(SizeProposal::exact(200.0, 100.0));
1421        let _ = tree.sync_accessibility();
1422
1423        assert_eq!(tree.at_version().get(), version);
1424    }
1425
1426    #[test]
1427    fn resizing_a_widget_dirties_the_accessibility_tree() {
1428        // A wrapped label re-wraps at a new width, so its lines — and
1429        // therefore its text runs — are a different set, not the same set
1430        // somewhere else. Only a full walk can produce them.
1431        let mut tree = WidgetTree::new();
1432        let id = tree.add(FillWidget::new().label("Static"));
1433        tree.layout(SizeProposal::exact(200.0, 100.0));
1434        let _ = tree.sync_accessibility();
1435        let walks = tree.a11y_walk_generation();
1436
1437        tree.layout(SizeProposal::exact(300.0, 100.0));
1438        let _ = tree.sync_accessibility();
1439
1440        assert!(
1441            tree.a11y_walk_generation() > walks,
1442            "a resize must be walked, not patched"
1443        );
1444        let _ = id;
1445    }
1446
1447    #[test]
1448    fn changing_the_theme_dirties_the_accessibility_tree() {
1449        // Typography is re-resolved, so every label re-shapes — inside
1450        // bounds the layout pass may leave untouched, which records no
1451        // resize and would otherwise leave the runs describing the old
1452        // metrics.
1453        let mut tree = WidgetTree::new();
1454        tree.add(FillWidget::new().label("Static"));
1455        tree.layout(SizeProposal::exact(200.0, 100.0));
1456        let _ = tree.sync_accessibility();
1457        let walks = tree.a11y_walk_generation();
1458
1459        tree.set_theme(crate::presets::intui::dark());
1460        let _ = tree.sync_accessibility();
1461
1462        assert!(tree.a11y_walk_generation() > walks);
1463    }
1464
1465    #[test]
1466    fn changing_the_text_scale_dirties_the_accessibility_tree() {
1467        let mut tree = WidgetTree::new();
1468        tree.add(FillWidget::new().label("Static"));
1469        tree.layout(SizeProposal::exact(200.0, 100.0));
1470        let _ = tree.sync_accessibility();
1471        let walks = tree.a11y_walk_generation();
1472
1473        tree.set_user_text_scale(1.5);
1474        let _ = tree.sync_accessibility();
1475
1476        assert!(tree.a11y_walk_generation() > walks);
1477    }
1478
1479    #[test]
1480    fn a_moved_node_absent_from_the_cache_is_skipped() {
1481        // A layout stack the walker prunes, an excluded or merged
1482        // descendant: the id moved, but the tree never had a node for it.
1483        // Inventing one would put a node in the update with no parent,
1484        // which panics the consumer's tree builder.
1485        use crate::widget_builder::WidgetBuilder;
1486        let mut tree = WidgetTree::new();
1487        let hidden = tree.add(FillWidget::new().label("Inner"));
1488        let offset = crate::signal::Signal::new(0.0f32);
1489        let root = tree.add(
1490            Mover {
1491                offset: offset.clone(),
1492                child: hidden,
1493            }
1494            .access_exclude_subtree(),
1495        );
1496        tree.layout(SizeProposal::exact(200.0, 100.0));
1497        let update = tree.sync_accessibility();
1498        assert!(
1499            find_node(&update, hidden).is_none(),
1500            "the excluded descendant must be absent for this test to mean anything"
1501        );
1502
1503        offset.set(20.0);
1504        tree.arena.mark_needs_layout(root);
1505        tree.layout(SizeProposal::exact(200.0, 100.0));
1506        let update = tree.sync_accessibility();
1507
1508        assert!(find_node(&update, hidden).is_none());
1509        assert_no_dangling_relationships(&update);
1510    }
1511
1512    /// Companion to the regression above: the dormant→active path
1513    /// MUST still dirty the AT cache, because the accessibility walk
1514    /// skips dormant nodes.
1515    #[test]
1516    fn activation_transition_does_dirty_a11y() {
1517        let mut tree = WidgetTree::new();
1518        let id = tree.add(FillWidget::new().label("Toggle"));
1519        tree.layout(SizeProposal::exact(200.0, 100.0));
1520        let _ = tree.sync_accessibility();
1521        assert!(!tree.a11y_dirty);
1522
1523        tree.set_dormant(id);
1524        tree.layout(SizeProposal::exact(200.0, 100.0));
1525        assert!(
1526            tree.a11y_dirty,
1527            "active→dormant transition must dirty the AT cache so the dormant node is removed"
1528        );
1529    }
1530
1531    #[test]
1532    fn sync_accessibility_includes_focus() {
1533        let mut tree = WidgetTree::new();
1534        let widget = tree.add(FillWidget::new().focusable().label("Focused"));
1535        tree.layout(SizeProposal::exact(100.0, 50.0));
1536        tree.focus(widget);
1537
1538        let update = tree.sync_accessibility();
1539        let expected_focus = crate::accessibility::widget_id_to_node_id(widget);
1540        assert_eq!(update.focus, expected_focus);
1541        assert_a11y_tree_valid(&update);
1542    }
1543
1544    #[test]
1545    fn sync_accessibility_parent_child_relationship() {
1546        let mut tree = WidgetTree::new();
1547        let child = tree.add(FillWidget::new().label("Child"));
1548        // A label keeps the parent from being collapsed as a presentational
1549        // container, so this exercises the parent→child push (not pruning).
1550        let parent = tree.add(
1551            StackWidget::new()
1552                .add_child(child)
1553                .access_label_literal("Parent"),
1554        );
1555        tree.layout(SizeProposal::exact(100.0, 50.0));
1556
1557        let update = tree.sync_accessibility();
1558        assert_eq!(update.nodes.len(), 3 + ANNOUNCER_NODES);
1559
1560        let parent_node_id = crate::accessibility::widget_id_to_node_id(parent);
1561        let parent_node = update
1562            .nodes
1563            .iter()
1564            .find(|(id, _)| *id == parent_node_id)
1565            .map(|(_, node)| node)
1566            .unwrap();
1567
1568        let child_node_id = crate::accessibility::widget_id_to_node_id(child);
1569        assert!(parent_node.children().contains(&child_node_id));
1570        assert_a11y_tree_valid(&update);
1571    }
1572
1573    #[test]
1574    fn presentational_containers_collapse_and_promote_children() {
1575        // A chain of bare presentational containers (StackWidget → empty
1576        // `Role::Unknown`) wrapping a labeled leaf collapses entirely: the
1577        // leaf is promoted to its nearest semantic ancestor, and no empty
1578        // grouping node remains (VoiceOver would announce one as "group").
1579        // A *labeled* container is semantic and survives.
1580        let mut tree = WidgetTree::new();
1581        let leaf = tree.add(FillWidget::new().label("Leaf")); // Role::Label
1582        let inner = tree.add(StackWidget::new().add_child(leaf)); // bare → pruned
1583        let outer = tree.add(StackWidget::new().add_child(inner)); // bare → pruned
1584        let labeled = tree.add(
1585            StackWidget::new()
1586                .add_child(outer)
1587                .access_label_literal("Group"),
1588        );
1589        tree.layout(SizeProposal::exact(100.0, 50.0));
1590        let update = tree.sync_accessibility();
1591
1592        assert!(find_node(&update, inner).is_none(), "bare inner pruned");
1593        assert!(find_node(&update, outer).is_none(), "bare outer pruned");
1594
1595        let labeled_node = find_node(&update, labeled).expect("labeled group survives");
1596        let leaf_nid = crate::accessibility::widget_id_to_node_id(leaf);
1597        assert!(
1598            labeled_node.children().contains(&leaf_nid),
1599            "leaf promoted past both bare containers to the labeled ancestor"
1600        );
1601        assert!(find_node(&update, leaf).is_some(), "labeled leaf kept");
1602        assert_a11y_tree_valid(&update);
1603    }
1604
1605    #[test]
1606    fn find_by_action_finds_clickable() {
1607        let mut tree = WidgetTree::new();
1608        let widget = tree.add(ClickableWidget);
1609        tree.layout(SizeProposal::exact(100.0, 40.0));
1610
1611        assert_eq!(tree.find_by_action(accesskit::Action::Click), Some(widget));
1612        assert_eq!(tree.find_by_action(accesskit::Action::Focus), None);
1613    }
1614
1615    #[test]
1616    fn text_content_returns_accessibility_name() {
1617        let mut tree = WidgetTree::new();
1618        let widget = tree.add(FillWidget::new().label("Hello World"));
1619        tree.layout(SizeProposal::exact(100.0, 50.0));
1620
1621        assert_eq!(tree.text_content(widget), Some("Hello World".to_string()));
1622    }
1623
1624    #[test]
1625    fn text_content_returns_none_without_label() {
1626        let mut tree = WidgetTree::new();
1627        let widget = tree.add(FillWidget::new());
1628        tree.layout(SizeProposal::exact(100.0, 50.0));
1629
1630        assert_eq!(tree.text_content(widget), None);
1631    }
1632
1633    #[test]
1634    fn descendant_of_disabled_ancestor_reports_disabled() {
1635        use crate::signal::Signal;
1636
1637        let mut tree = WidgetTree::new();
1638        let child = tree.add(FillWidget::new().label("Child"));
1639        let parent = tree.add(StackWidget::new().add_child(child));
1640        tree.enabled_when(parent, Signal::new(false));
1641        tree.layout(SizeProposal::exact(100.0, 50.0));
1642
1643        assert!(
1644            tree.accessibility_node(child).is_disabled(),
1645            "descendant should report disabled when ancestor is disabled"
1646        );
1647    }
1648
1649    #[test]
1650    fn text_value_returns_accessibility_value() {
1651        #[derive(Debug)]
1652        struct ValueWidget;
1653
1654        impl Widget for ValueWidget {
1655            fn layout_response(
1656                &self,
1657                proposal: SizeProposal,
1658                _ctx: &LayoutContext,
1659            ) -> crate::widget::LayoutResponse {
1660                proposal.resolve(0.0, 0.0).into()
1661            }
1662
1663            fn accessibility(&self, builder: &mut crate::accessibility::AccessNodeBuilder) {
1664                builder.set_role(accesskit::Role::Slider);
1665                builder.set_name("Volume");
1666                builder.set_value("75%");
1667            }
1668        }
1669
1670        let mut tree = WidgetTree::new();
1671        let widget = tree.add(ValueWidget);
1672        tree.layout(SizeProposal::exact(100.0, 40.0));
1673
1674        assert_eq!(tree.text_value(widget), Some("75%".to_string()));
1675        assert_eq!(tree.text_content(widget), Some("Volume".to_string()));
1676    }
1677
1678    #[test]
1679    fn sync_accessibility_has_no_duplicate_children() {
1680        // Regression test for the AccessKit "duplicate child" crash (VoiceOver/NVDA).
1681        // assert_a11y_tree_valid already catches this via the consumer, but the
1682        // manual check here provides a more actionable failure message.
1683        let mut tree = WidgetTree::new();
1684        let grandchild = tree.add(FillWidget::new().label("Grandchild"));
1685        let child_a = tree.add(StackWidget::new().add_child(grandchild));
1686        let child_b = tree.add(FillWidget::new().label("Sibling"));
1687        let _root = tree.add(StackWidget::new().add_child(child_a).add_child(child_b));
1688        tree.layout(SizeProposal::exact(200.0, 100.0));
1689
1690        let update = tree.sync_accessibility();
1691
1692        let mut all_children: std::collections::HashMap<accesskit::NodeId, accesskit::NodeId> =
1693            std::collections::HashMap::new();
1694        for (parent_id, node) in &update.nodes {
1695            for &child_id in node.children() {
1696                let prev = all_children.insert(child_id, *parent_id);
1697                assert!(
1698                    prev.is_none(),
1699                    "duplicate child NodeId {child_id:?}: claimed by both {prev:?} and {parent_id:?}"
1700                );
1701            }
1702        }
1703        assert_a11y_tree_valid(&update);
1704    }
1705
1706    #[test]
1707    fn no_dangling_relationships_in_basic_tree() {
1708        let mut tree = WidgetTree::new();
1709        let child = tree.add(FillWidget::new().label("Child"));
1710        let _parent = tree.add(StackWidget::new().add_child(child));
1711        tree.layout(SizeProposal::exact(100.0, 50.0));
1712
1713        let update = tree.sync_accessibility();
1714        assert_no_dangling_relationships(&update);
1715        assert_a11y_tree_valid(&update);
1716    }
1717
1718    // ── Builder-level accessibility override tests ───────────────────
1719    //
1720    // Tests for `WidgetBuilder::access_*` methods.
1721
1722    use crate::widget_builder::WidgetBuilder;
1723    use accesskit::{Action, AriaCurrent, HasPopup, Live, Orientation, Role};
1724
1725    /// Find a node in a TreeUpdate by WidgetId.
1726    fn find_node(update: &accesskit::TreeUpdate, id: WidgetId) -> Option<&accesskit::Node> {
1727        let nid = crate::accessibility::widget_id_to_node_id(id);
1728        update
1729            .nodes
1730            .iter()
1731            .find(|(node_id, _)| *node_id == nid)
1732            .map(|(_, n)| n)
1733    }
1734
1735    /// A widget that calls set_hidden() unconditionally — used to test
1736    /// `access_hidden(false)` clears widget-emitted hidden state.
1737    #[derive(Debug)]
1738    struct AlwaysHiddenWidget;
1739    impl Widget for AlwaysHiddenWidget {
1740        fn layout_response(
1741            &self,
1742            proposal: SizeProposal,
1743            _ctx: &LayoutContext,
1744        ) -> crate::widget::LayoutResponse {
1745            proposal.resolve(0.0, 0.0).into()
1746        }
1747        fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1748            builder.set_role(Role::GenericContainer);
1749            builder.set_hidden();
1750        }
1751    }
1752
1753    // Test 1
1754    #[test]
1755    fn access_label_replaces_widget_label() {
1756        let mut tree = WidgetTree::new();
1757        let id = tree.add(ClickableWidget.access_label_literal("Publish"));
1758        tree.layout(SizeProposal::exact(100.0, 40.0));
1759        assert_eq!(tree.accessibility_node(id).name(), Some("Publish"));
1760    }
1761
1762    // Test 2
1763    #[test]
1764    fn access_description_appears_on_bare_widget() {
1765        let mut tree = WidgetTree::new();
1766        let id = tree.add(FillWidget::new().access_description_literal("Decorative"));
1767        tree.layout(SizeProposal::exact(50.0, 50.0));
1768        let update = tree.sync_accessibility();
1769        let node = find_node(&update, id).expect("node present");
1770        assert_eq!(node.description(), Some("Decorative"));
1771    }
1772
1773    // Test 3
1774    #[test]
1775    fn access_value_replaces_widget_value() {
1776        #[derive(Debug)]
1777        struct SliderWidget;
1778        impl Widget for SliderWidget {
1779            fn layout_response(
1780                &self,
1781                proposal: SizeProposal,
1782                _ctx: &LayoutContext,
1783            ) -> crate::widget::LayoutResponse {
1784                proposal.resolve(0.0, 0.0).into()
1785            }
1786            fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1787                builder.set_role(Role::Slider);
1788                builder.set_value("50");
1789            }
1790        }
1791        let mut tree = WidgetTree::new();
1792        let id = tree.add(SliderWidget.access_value_literal("Custom"));
1793        tree.layout(SizeProposal::exact(100.0, 40.0));
1794        assert_eq!(tree.text_value(id), Some("Custom".to_string()));
1795    }
1796
1797    // Test 4
1798    #[test]
1799    fn access_role_overrides_widget_role() {
1800        let mut tree = WidgetTree::new();
1801        let id = tree.add(FillWidget::new().label("H").access_role(Role::Heading));
1802        tree.layout(SizeProposal::exact(100.0, 40.0));
1803        assert_eq!(tree.accessibility_node(id).role(), Role::Heading);
1804    }
1805
1806    #[test]
1807    fn an_unshown_tooltip_still_describes_its_anchor_for_screen_readers() {
1808        // The pass used to wire `described_by` only while the tooltip's
1809        // overlay was live. A plain tooltip is never auto-shown on focus, and
1810        // a dormant content node is excluded from the emitted tree (so it
1811        // cannot be a relation target anyway) — which left the whole plain
1812        // tier with no screen-reader path at all. With no hover, the anchor
1813        // must still carry the text as a static description.
1814        let mut tree = WidgetTree::new();
1815        let anchor = tree.add(FillWidget::new().label("Save"));
1816        let tip = tree.add(FillWidget::new().label("Save the file"));
1817        tree.layout(SizeProposal::exact(200.0, 100.0));
1818        tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(500));
1819
1820        assert!(tree.active_overlays().is_empty(), "not hovered, not shown");
1821        let update = tree.sync_accessibility();
1822        let node = find_node(&update, anchor).expect("anchor node present");
1823        assert_eq!(
1824            node.description(),
1825            Some("Save the file"),
1826            "the anchor must describe itself with its tooltip text while unshown"
1827        );
1828    }
1829
1830    #[test]
1831    fn a_shown_tooltip_switches_the_anchor_to_the_described_by_relation() {
1832        // Once the content is genuinely in the tree, the richer relation is
1833        // used instead of the copied string.
1834        let mut tree = WidgetTree::new();
1835        let anchor = tree.add(FillWidget::new().label("Save"));
1836        let tip = tree.add(FillWidget::new().label("Save the file"));
1837        tree.layout(SizeProposal::exact(200.0, 100.0));
1838        tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
1839
1840        tree.pointer_move(tree.bounds(anchor).center());
1841        tree.advance_time(std::time::Duration::from_millis(150));
1842        assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
1843
1844        let update = tree.sync_accessibility();
1845        let node = find_node(&update, anchor).expect("anchor node present");
1846        assert!(
1847            !node.described_by().is_empty(),
1848            "a shown tooltip is wired as a described_by relation"
1849        );
1850    }
1851
1852    // Test 5
1853    #[test]
1854    fn access_hint_alias_writes_description_field() {
1855        let mut tree = WidgetTree::new();
1856        let id = tree.add(FillWidget::new().access_hint_literal("Tip"));
1857        tree.layout(SizeProposal::exact(50.0, 50.0));
1858        let update = tree.sync_accessibility();
1859        let node = find_node(&update, id).unwrap();
1860        assert_eq!(node.description(), Some("Tip"));
1861    }
1862
1863    // Test 6
1864    #[test]
1865    fn access_identifier_writes_author_id() {
1866        let mut tree = WidgetTree::new();
1867        let id = tree.add(FillWidget::new().access_identifier("save-button"));
1868        tree.layout(SizeProposal::exact(50.0, 50.0));
1869        let update = tree.sync_accessibility();
1870        let node = find_node(&update, id).unwrap();
1871        assert_eq!(node.author_id(), Some("save-button"));
1872    }
1873
1874    // Test 7a — literal shortcut variant
1875    #[test]
1876    fn access_shortcut_literal_set() {
1877        let mut tree = WidgetTree::new();
1878        let id = tree.add(ClickableWidget.access_shortcut_literal("Ctrl+S"));
1879        tree.layout(SizeProposal::exact(50.0, 50.0));
1880        let update = tree.sync_accessibility();
1881        let node = find_node(&update, id).unwrap();
1882        assert_eq!(node.keyboard_shortcut(), Some("Ctrl+S"));
1883    }
1884
1885    // Test 7b — id-based shortcut resolves through ShortcutRegistry,
1886    // including auto-refresh on rebind.
1887    #[test]
1888    fn access_shortcut_id_resolves_and_tracks_rebinds() {
1889        use crate::event::Key;
1890        use crate::shortcut::{KeyStroke, Shortcut};
1891
1892        let mut tree = WidgetTree::new();
1893        tree.shortcut_registry_mut().register(
1894            Shortcut::new("app.save")
1895                .name("Save")
1896                .primary(KeyStroke::command(Key::S))
1897                .build(),
1898        );
1899        let id = tree.add(ClickableWidget.access_shortcut_id("app.save"));
1900        tree.layout(SizeProposal::exact(50.0, 50.0));
1901
1902        // The chord is declared on the primary accelerator, so the announced
1903        // name is the key the user is actually looking at: ⌘ on macOS, Ctrl
1904        // everywhere else (see `Modifiers`' `Display`).
1905        let accel = if cfg!(target_os = "macos") {
1906            "Cmd"
1907        } else {
1908            "Ctrl"
1909        };
1910
1911        // Initial registration: AT announces the default keystroke.
1912        let update = tree.sync_accessibility();
1913        let node = find_node(&update, id).unwrap();
1914        assert_eq!(
1915            node.keyboard_shortcut(),
1916            Some(format!("{accel}+S").as_str())
1917        );
1918
1919        // Simulate a user rebind: the AT announcement should track it.
1920        tree.shortcut_registry_mut()
1921            .rebind_primary("app.save", Some(KeyStroke::command(Key::Q)));
1922        let update = tree.sync_accessibility();
1923        let node = find_node(&update, id).unwrap();
1924        assert_eq!(
1925            node.keyboard_shortcut(),
1926            Some(format!("{accel}+Q").as_str())
1927        );
1928    }
1929
1930    // Test 7c — silently omits the announcement when the id has no
1931    // registered default. Same fallback behavior as `MenuItem::for_shortcut`.
1932    #[test]
1933    fn access_shortcut_id_unknown_id_omits_announcement() {
1934        let mut tree = WidgetTree::new();
1935        let id = tree.add(ClickableWidget.access_shortcut_id("never.registered"));
1936        tree.layout(SizeProposal::exact(50.0, 50.0));
1937        let update = tree.sync_accessibility();
1938        let node = find_node(&update, id).unwrap();
1939        assert_eq!(node.keyboard_shortcut(), None);
1940    }
1941
1942    // Test 8
1943    #[test]
1944    fn access_hidden_true_hides_widget() {
1945        let mut tree = WidgetTree::new();
1946        let id = tree.add(ClickableWidget.access_hidden(true));
1947        tree.layout(SizeProposal::exact(50.0, 50.0));
1948        assert!(tree.accessibility_node(id).is_hidden());
1949    }
1950
1951    // Test 9
1952    #[test]
1953    fn access_hidden_false_clears_widget_set_hidden() {
1954        let mut tree = WidgetTree::new();
1955        let id = tree.add(AlwaysHiddenWidget.access_hidden(false));
1956        tree.layout(SizeProposal::exact(50.0, 50.0));
1957        assert!(
1958            !tree.accessibility_node(id).is_hidden(),
1959            "access_hidden(false) should clear widget-emitted hidden"
1960        );
1961    }
1962
1963    // Test 10
1964    #[test]
1965    fn access_disabled_true_marks_disabled() {
1966        let mut tree = WidgetTree::new();
1967        let id = tree.add(FillWidget::new().access_disabled(true));
1968        tree.layout(SizeProposal::exact(50.0, 50.0));
1969        assert!(tree.accessibility_node(id).is_disabled());
1970    }
1971
1972    // Test 11
1973    #[test]
1974    fn access_disabled_false_clears_arena_driven_disabled() {
1975        use crate::signal::Signal;
1976        let mut tree = WidgetTree::new();
1977        let id = tree.add(FillWidget::new().label("X").access_disabled(false));
1978        tree.enabled_when(id, Signal::new(false));
1979        tree.layout(SizeProposal::exact(50.0, 50.0));
1980        assert!(
1981            !tree.accessibility_node(id).is_disabled(),
1982            "access_disabled(false) should clear even arena-driven disabled"
1983        );
1984    }
1985
1986    // Test 12
1987    #[test]
1988    fn access_controls_appends() {
1989        let mut tree = WidgetTree::new();
1990        let target = tree.add(FillWidget::new().label("Target"));
1991        let controller = tree.add(
1992            FillWidget::new()
1993                .label("Controller")
1994                .access_controls(target),
1995        );
1996        tree.layout(SizeProposal::exact(100.0, 50.0));
1997        let update = tree.sync_accessibility();
1998        let node = find_node(&update, controller).unwrap();
1999        let target_nid = crate::accessibility::widget_id_to_node_id(target);
2000        assert!(
2001            node.controls().contains(&target_nid),
2002            "controls list should contain the target NodeId"
2003        );
2004    }
2005
2006    // Test 13
2007    #[test]
2008    fn access_described_by_appends() {
2009        let mut tree = WidgetTree::new();
2010        let other = tree.add(FillWidget::new().label("Desc"));
2011        let id = tree.add(FillWidget::new().label("Main").access_described_by(other));
2012        tree.layout(SizeProposal::exact(100.0, 50.0));
2013        let update = tree.sync_accessibility();
2014        let node = find_node(&update, id).unwrap();
2015        let other_nid = crate::accessibility::widget_id_to_node_id(other);
2016        assert!(node.described_by().contains(&other_nid));
2017    }
2018
2019    // Test 14
2020    #[test]
2021    fn access_labelled_by_appends() {
2022        let mut tree = WidgetTree::new();
2023        let other = tree.add(FillWidget::new().label("Lbl"));
2024        let id = tree.add(FillWidget::new().label("Main").access_labelled_by(other));
2025        tree.layout(SizeProposal::exact(100.0, 50.0));
2026        let update = tree.sync_accessibility();
2027        let node = find_node(&update, id).unwrap();
2028        let other_nid = crate::accessibility::widget_id_to_node_id(other);
2029        assert!(node.labelled_by().contains(&other_nid));
2030    }
2031
2032    #[test]
2033    fn a_labelled_by_target_that_went_dormant_is_stripped() {
2034        // A dangling `labelled_by` is worse than a dangling `controls`: the
2035        // consumer builds the node's *name* by concatenating its targets'
2036        // values, and walks the relation to do it. `accesskit_consumer`'s
2037        // relation iterator unwraps the lookup, so a target that never
2038        // reached the tree panics the walk rather than merely losing a link.
2039        let mut tree = WidgetTree::new();
2040        let title = tree.add(FillWidget::new().label("Preferences"));
2041        let dialog = tree.add(FillWidget::new().access_labelled_by(title));
2042        tree.layout(SizeProposal::exact(200.0, 100.0));
2043
2044        let update = tree.sync_accessibility();
2045        assert!(
2046            !find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2047            "the relation is live while the target is in the tree"
2048        );
2049
2050        tree.set_dormant(title);
2051        let update = tree.sync_accessibility();
2052        assert!(
2053            find_node(&update, dialog).unwrap().labelled_by().is_empty(),
2054            "a target absent from the tree must not survive in the relation"
2055        );
2056        assert_no_dangling_relationships(&update);
2057    }
2058
2059    #[test]
2060    fn re_registering_a_labelled_by_relation_does_not_duplicate_it() {
2061        // A composite that names itself from its own content registers the
2062        // relation from `build()`, which runs again on every rebuild. Appending
2063        // blindly would concatenate the title into the name once per rebuild.
2064        let mut tree = WidgetTree::new();
2065        let title = tree.add(FillWidget::new().label("Name"));
2066        let field = tree.add(FillWidget::new());
2067        tree.layout(SizeProposal::exact(200.0, 100.0));
2068
2069        for _ in 0..3 {
2070            tree.push_access_labelled_by(field, title);
2071        }
2072        let update = tree.sync_accessibility();
2073        assert_eq!(
2074            find_node(&update, field).unwrap().labelled_by().len(),
2075            1,
2076            "the same target registered repeatedly is one relation"
2077        );
2078    }
2079
2080    #[test]
2081    fn re_registering_a_described_by_relation_does_not_duplicate_it() {
2082        let mut tree = WidgetTree::new();
2083        let hint = tree.add(FillWidget::new().label("Must be unique"));
2084        let field = tree.add(FillWidget::new());
2085        tree.layout(SizeProposal::exact(200.0, 100.0));
2086
2087        for _ in 0..3 {
2088            tree.push_access_described_by(field, hint);
2089        }
2090        let update = tree.sync_accessibility();
2091        assert_eq!(
2092            find_node(&update, field).unwrap().described_by().len(),
2093            1,
2094            "the same target registered repeatedly is one relation"
2095        );
2096    }
2097
2098    // Test 15
2099    #[test]
2100    fn access_live_assertive_set() {
2101        let mut tree = WidgetTree::new();
2102        let id = tree.add(FillWidget::new().access_live(Live::Assertive));
2103        tree.layout(SizeProposal::exact(50.0, 50.0));
2104        let update = tree.sync_accessibility();
2105        let node = find_node(&update, id).unwrap();
2106        assert_eq!(node.live(), Some(Live::Assertive));
2107    }
2108
2109    // Test 16
2110    #[test]
2111    fn access_aria_current_set() {
2112        let mut tree = WidgetTree::new();
2113        let id = tree.add(FillWidget::new().access_current(AriaCurrent::Page));
2114        tree.layout(SizeProposal::exact(50.0, 50.0));
2115        let update = tree.sync_accessibility();
2116        let node = find_node(&update, id).unwrap();
2117        assert_eq!(node.aria_current(), Some(AriaCurrent::Page));
2118    }
2119
2120    // Test 17
2121    #[test]
2122    fn access_has_popup_set() {
2123        let mut tree = WidgetTree::new();
2124        let id = tree.add(ClickableWidget.access_has_popup(HasPopup::Menu));
2125        tree.layout(SizeProposal::exact(50.0, 50.0));
2126        let update = tree.sync_accessibility();
2127        let node = find_node(&update, id).unwrap();
2128        assert_eq!(node.has_popup(), Some(HasPopup::Menu));
2129    }
2130
2131    // Test 18
2132    #[test]
2133    fn access_orientation_set() {
2134        let mut tree = WidgetTree::new();
2135        let id = tree.add(FillWidget::new().access_orientation(Orientation::Vertical));
2136        tree.layout(SizeProposal::exact(50.0, 50.0));
2137        let update = tree.sync_accessibility();
2138        let node = find_node(&update, id).unwrap();
2139        assert_eq!(node.orientation(), Some(Orientation::Vertical));
2140    }
2141
2142    // Test 19
2143    #[test]
2144    fn access_numeric_value_and_range() {
2145        let mut tree = WidgetTree::new();
2146        let id = tree.add(
2147            FillWidget::new()
2148                .access_role(Role::Slider)
2149                .access_numeric_value(50.0)
2150                .access_numeric_range(0.0, 100.0)
2151                .access_numeric_step(5.0),
2152        );
2153        tree.layout(SizeProposal::exact(50.0, 50.0));
2154        let update = tree.sync_accessibility();
2155        let node = find_node(&update, id).unwrap();
2156        assert_eq!(node.numeric_value(), Some(50.0));
2157        assert_eq!(node.min_numeric_value(), Some(0.0));
2158        assert_eq!(node.max_numeric_value(), Some(100.0));
2159        assert_eq!(node.numeric_value_step(), Some(5.0));
2160    }
2161
2162    // Test 20
2163    #[test]
2164    fn access_action_advertises_and_routes() {
2165        use crate::signal::Signal;
2166        let flag = Signal::new(false);
2167        let flag_for_cb = flag.clone();
2168        let mut tree = WidgetTree::new();
2169        let id = tree.add(
2170            FillWidget::new()
2171                .access_action(Action::ShowContextMenu, move |_ctx| flag_for_cb.set(true)),
2172        );
2173        tree.layout(SizeProposal::exact(50.0, 50.0));
2174        let info = tree.accessibility_node(id);
2175        assert!(info.actions().contains(&Action::ShowContextMenu));
2176        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2177            action: Action::ShowContextMenu,
2178            target: Some(id),
2179            target_node: crate::accessibility::widget_id_to_node_id(id),
2180            data: None,
2181        });
2182        assert!(flag.get(), "callback should have been invoked");
2183    }
2184
2185    // Test 21
2186    #[test]
2187    fn access_two_actions_both_route() {
2188        use crate::signal::Signal;
2189        let click = Signal::new(false);
2190        let increment = Signal::new(false);
2191        let click_cb = click.clone();
2192        let inc_cb = increment.clone();
2193        let mut tree = WidgetTree::new();
2194        let id = tree.add(
2195            FillWidget::new()
2196                .access_action(Action::ShowContextMenu, move |_| click_cb.set(true))
2197                .access_action(Action::Increment, move |_| inc_cb.set(true)),
2198        );
2199        tree.layout(SizeProposal::exact(50.0, 50.0));
2200        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2201            action: Action::ShowContextMenu,
2202            target: Some(id),
2203            target_node: crate::accessibility::widget_id_to_node_id(id),
2204            data: None,
2205        });
2206        assert!(click.get() && !increment.get(), "only first action fired");
2207        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2208            action: Action::Increment,
2209            target: Some(id),
2210            target_node: crate::accessibility::widget_id_to_node_id(id),
2211            data: None,
2212        });
2213        assert!(
2214            click.get() && increment.get(),
2215            "both actions fired exactly once each"
2216        );
2217    }
2218
2219    // Test 22
2220    #[test]
2221    fn access_remove_action_suppresses_widget_action() {
2222        let mut tree = WidgetTree::new();
2223        let id = tree.add(ActionWidget.access_remove_action(Action::Click));
2224        tree.layout(SizeProposal::exact(50.0, 50.0));
2225        let info = tree.accessibility_node(id);
2226        assert!(!info.actions().contains(&Action::Click));
2227        assert!(info.actions().contains(&Action::Focus));
2228    }
2229
2230    // Test 23
2231    #[test]
2232    fn access_custom_action_uses_localized_label() {
2233        let mut tree = WidgetTree::new();
2234        let id = tree.add(FillWidget::new().access_custom_action_literal("Reply", |_ctx| {}));
2235        tree.layout(SizeProposal::exact(50.0, 50.0));
2236        let update = tree.sync_accessibility();
2237        let node = find_node(&update, id).unwrap();
2238        let actions = node.custom_actions();
2239        assert_eq!(actions.len(), 1);
2240        assert_eq!(actions[0].id, 0);
2241        assert_eq!(actions[0].description.as_str(), "Reply");
2242    }
2243
2244    // Test 24
2245    #[test]
2246    fn access_custom_action_routes_by_index() {
2247        use crate::signal::Signal;
2248        let first = Signal::new(false);
2249        let second = Signal::new(false);
2250        let f = first.clone();
2251        let s = second.clone();
2252        let mut tree = WidgetTree::new();
2253        let id = tree.add(
2254            FillWidget::new()
2255                .access_custom_action_literal("First", move |_| f.set(true))
2256                .access_custom_action_literal("Second", move |_| s.set(true)),
2257        );
2258        tree.layout(SizeProposal::exact(50.0, 50.0));
2259        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2260            action: Action::CustomAction,
2261            target: Some(id),
2262            target_node: crate::accessibility::widget_id_to_node_id(id),
2263            data: Some(accesskit::ActionData::CustomAction(1)),
2264        });
2265        assert!(!first.get(), "first should not fire");
2266        assert!(second.get(), "second should fire (index 1)");
2267    }
2268
2269    // Test 25
2270    #[test]
2271    fn access_action_layered_with_on_access_action() {
2272        use crate::signal::Signal;
2273        let from_override = Signal::new(false);
2274        let from_user = Signal::new(false);
2275        let ov_cb = from_override.clone();
2276        let user_cb = from_user.clone();
2277        let mut tree = WidgetTree::new();
2278        let id = tree.add(
2279            FillWidget::new()
2280                .access_action(Action::ShowContextMenu, move |_| ov_cb.set(true))
2281                .on_access_action(move |_action, _ctx| {
2282                    user_cb.set(true);
2283                    crate::event::EventResponse::Handled
2284                }),
2285        );
2286        tree.layout(SizeProposal::exact(50.0, 50.0));
2287        tree.dispatch_event(crate::event::WidgetEvent::AccessAction {
2288            action: Action::ShowContextMenu,
2289            target: Some(id),
2290            target_node: crate::accessibility::widget_id_to_node_id(id),
2291            data: None,
2292        });
2293        assert!(from_override.get(), "override callback fired");
2294        assert!(from_user.get(), "user catch-all fired");
2295    }
2296
2297    // Test 26 — i18n integration via teksilo_i18n's
2298    // `From<LocalizedString> for Prop<String>` impl. We don't import
2299    // teksilo-i18n here (it depends on teksilo-core), but the conversion
2300    // works the same way for any `Into<Prop<String>>`. This stand-in
2301    // covers the same code path the FTL-bundle case takes.
2302    #[test]
2303    fn access_label_accepts_resolved_string_via_into() {
2304        // Simulate a `LocalizedString`-like wrapper: any type that
2305        // `impl Into<Prop<String>>`. The override surface stores the
2306        // prop and the walker reads its current value.
2307        struct ResolvedAtCall(String);
2308        impl From<ResolvedAtCall> for crate::signal::Prop<String> {
2309            fn from(v: ResolvedAtCall) -> crate::signal::Prop<String> {
2310                crate::signal::Prop::Static(v.0)
2311            }
2312        }
2313        let mut tree = WidgetTree::new();
2314        let id = tree.add(FillWidget::new().access_label(ResolvedAtCall("Save".to_string())));
2315        tree.layout(SizeProposal::exact(50.0, 50.0));
2316        assert_eq!(tree.accessibility_node(id).name(), Some("Save"));
2317    }
2318
2319    // Test 27
2320    #[test]
2321    fn access_exclude_subtree_prunes_children_from_at_tree() {
2322        let mut tree = WidgetTree::new();
2323        let inner1 = tree.add(FillWidget::new().label("A"));
2324        let inner2 = tree.add(FillWidget::new().label("B"));
2325        let outer = tree.add(
2326            StackWidget::new()
2327                .add_child(inner1)
2328                .add_child(inner2)
2329                // A label keeps `outer` from being collapsed as a
2330                // presentational container, so the test exercises Exclude
2331                // (not the new presentational-pruning pass).
2332                .access_label_literal("Section")
2333                .access_exclude_subtree(),
2334        );
2335        tree.layout(SizeProposal::exact(100.0, 50.0));
2336        let update = tree.sync_accessibility();
2337        // Outer is present; inner1 and inner2 are pruned.
2338        assert!(find_node(&update, outer).is_some());
2339        assert!(find_node(&update, inner1).is_none(), "inner1 pruned");
2340        assert!(find_node(&update, inner2).is_none(), "inner2 pruned");
2341        let outer_node = find_node(&update, outer).unwrap();
2342        assert!(
2343            outer_node.children().is_empty(),
2344            "outer should have no AT children when excluded"
2345        );
2346    }
2347
2348    // Test 28
2349    #[test]
2350    fn access_merge_subtree_concatenates_descendant_labels() {
2351        let mut tree = WidgetTree::new();
2352        let title = tree.add(FillWidget::new().label("Title"));
2353        let subtitle = tree.add(FillWidget::new().label("Subtitle"));
2354        let card = tree.add(
2355            StackWidget::new()
2356                .add_child(title)
2357                .add_child(subtitle)
2358                .access_merge_subtree(),
2359        );
2360        tree.layout(SizeProposal::exact(100.0, 50.0));
2361        // After merge: card's name is "Title Subtitle", children pruned.
2362        assert_eq!(tree.text_content(card), Some("Title Subtitle".to_string()));
2363        let update = tree.sync_accessibility();
2364        assert!(find_node(&update, title).is_none());
2365        assert!(find_node(&update, subtitle).is_none());
2366    }
2367
2368    // Test 29
2369    #[test]
2370    fn access_merge_subtree_unions_actions() {
2371        let mut tree = WidgetTree::new();
2372        let click_a = tree.add(ClickableWidget);
2373        let click_b = tree.add(ClickableWidget);
2374        let card = tree.add(
2375            StackWidget::new()
2376                .add_child(click_a)
2377                .add_child(click_b)
2378                .access_merge_subtree(),
2379        );
2380        tree.layout(SizeProposal::exact(100.0, 50.0));
2381        let actions = tree.accessibility_node(card).actions().to_vec();
2382        let click_count = actions.iter().filter(|a| **a == Action::Click).count();
2383        assert_eq!(
2384            click_count, 1,
2385            "Click should be present exactly once after merge (deduplicated)"
2386        );
2387    }
2388
2389    // Test 30 — covered by the i18n integration mechanism documented in
2390    // `From<LocalizedString> for String`. Since teksilo-core can't reference
2391    // LocalizedString, the merged-localized-label case is exercised by
2392    // tests 26 + 28 in combination: each child's resolved-at-call-time
2393    // String contributes to the merged label. The full FTL-bundle
2394    // round-trip is tested in the teksilo-i18n / teksilo-widgets integration
2395    // tests, not here.
2396
2397    // Test 31
2398    #[test]
2399    fn access_merge_subtree_first_nonempty_value_wins() {
2400        #[derive(Debug)]
2401        struct ValueWidget(&'static str);
2402        impl Widget for ValueWidget {
2403            fn layout_response(
2404                &self,
2405                proposal: SizeProposal,
2406                _ctx: &LayoutContext,
2407            ) -> crate::widget::LayoutResponse {
2408                proposal.resolve(0.0, 0.0).into()
2409            }
2410            fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2411                builder.set_role(Role::Slider);
2412                builder.set_value(self.0);
2413            }
2414        }
2415        let mut tree = WidgetTree::new();
2416        let v1 = tree.add(ValueWidget("first"));
2417        let v2 = tree.add(ValueWidget("second"));
2418        let card = tree.add(
2419            StackWidget::new()
2420                .add_child(v1)
2421                .add_child(v2)
2422                .access_merge_subtree(),
2423        );
2424        tree.layout(SizeProposal::exact(100.0, 50.0));
2425        assert_eq!(tree.text_value(card), Some("first".to_string()));
2426    }
2427
2428    // Test 32
2429    #[test]
2430    fn access_exclude_inside_merge() {
2431        let mut tree = WidgetTree::new();
2432        let visible = tree.add(FillWidget::new().label("VISIBLE"));
2433        let pruned = tree.add(FillWidget::new().label("PRUNED"));
2434        let inner_excluded = tree.add(
2435            StackWidget::new()
2436                .add_child(pruned)
2437                .access_exclude_subtree(),
2438        );
2439        let card = tree.add(
2440            StackWidget::new()
2441                .add_child(visible)
2442                .add_child(inner_excluded)
2443                .access_merge_subtree(),
2444        );
2445        tree.layout(SizeProposal::exact(100.0, 50.0));
2446        let merged = tree.text_content(card).unwrap_or_default();
2447        // VISIBLE present, PRUNED absent (because inner_excluded
2448        // pruned its own subtree before the merge could absorb it).
2449        assert!(merged.contains("VISIBLE"));
2450        assert!(
2451            !merged.contains("PRUNED"),
2452            "excluded subtree should not contribute to merge"
2453        );
2454    }
2455
2456    // Test 33
2457    #[test]
2458    fn access_merge_inside_merge() {
2459        let mut tree = WidgetTree::new();
2460        let inner_a = tree.add(FillWidget::new().label("a"));
2461        let inner_b = tree.add(FillWidget::new().label("b"));
2462        let inner_card = tree.add(
2463            StackWidget::new()
2464                .add_child(inner_a)
2465                .add_child(inner_b)
2466                .access_merge_subtree(),
2467        );
2468        let outer_extra = tree.add(FillWidget::new().label("X"));
2469        let outer = tree.add(
2470            StackWidget::new()
2471                .add_child(inner_card)
2472                .add_child(outer_extra)
2473                .access_merge_subtree(),
2474        );
2475        tree.layout(SizeProposal::exact(200.0, 100.0));
2476        // Outer absorbs inner_card's already-merged label ("a b") AND
2477        // outer_extra ("X"), giving something like "a b X" or "X a b".
2478        // Order is descendant-walk order; we just verify all parts
2479        // appear and inner children are NOT double-counted.
2480        let merged = tree.text_content(outer).unwrap_or_default();
2481        assert!(merged.contains("a"));
2482        assert!(merged.contains("b"));
2483        assert!(merged.contains("X"));
2484        // Inner children should not appear AS THEIR OWN nodes:
2485        let update = tree.sync_accessibility();
2486        assert!(find_node(&update, inner_a).is_none());
2487        assert!(find_node(&update, inner_b).is_none());
2488        assert!(find_node(&update, inner_card).is_none());
2489        assert!(find_node(&update, outer_extra).is_none());
2490    }
2491
2492    // Test 34
2493    #[test]
2494    fn access_customize_runs_last() {
2495        let mut tree = WidgetTree::new();
2496        let id = tree.add(
2497            FillWidget::new()
2498                .access_label_literal("A")
2499                .access_customize(|b| b.set_name("B")),
2500        );
2501        tree.layout(SizeProposal::exact(50.0, 50.0));
2502        assert_eq!(tree.accessibility_node(id).name(), Some("B"));
2503    }
2504
2505    // Test 35
2506    #[test]
2507    fn access_customize_can_reach_inner_mut() {
2508        let mut tree = WidgetTree::new();
2509        let id = tree.add(FillWidget::new().access_customize(|b| {
2510            b.inner_mut().set_author_id("from-customize");
2511        }));
2512        tree.layout(SizeProposal::exact(50.0, 50.0));
2513        let update = tree.sync_accessibility();
2514        let node = find_node(&update, id).unwrap();
2515        assert_eq!(node.author_id(), Some("from-customize"));
2516    }
2517
2518    // Test 36 — sanity guard. WidgetNode size delta when no overrides.
2519    #[test]
2520    fn access_overrides_zero_cost_when_unused() {
2521        // The override fields add only `Option<Box<...>>` (8 bytes for
2522        // the pointer-sized null) + `AccessSubtreeMode` (1 byte enum,
2523        // padded). Sanity: at most 16 bytes added.
2524        // We don't assert a specific size because struct layout shifts
2525        // with rustc versions; we just confirm both fields are
2526        // pointer/byte sized.
2527        use std::mem::size_of;
2528        assert!(size_of::<Option<Box<crate::widget_builder::AccessibilityOverrides>>>() <= 16);
2529        assert!(size_of::<crate::widget_builder::AccessSubtreeMode>() <= 4);
2530    }
2531
2532    #[test]
2533    fn accessibility_children_overrides_at_reading_order() {
2534        // Audit G17: a widget can present a different child ORDER to assistive
2535        // tech than its layout/paint child order via `accessibility_children()`
2536        // — the mechanism TableView/TreeTableView use to read the header before
2537        // the body even though they build the body first (z-order).
2538        #[derive(Debug)]
2539        struct NamedLeaf(&'static str);
2540        impl Widget for NamedLeaf {
2541            fn layout_response(
2542                &self,
2543                proposal: SizeProposal,
2544                _ctx: &LayoutContext,
2545            ) -> crate::widget::LayoutResponse {
2546                proposal.resolve(10.0, 10.0).into()
2547            }
2548            fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2549                // Button keeps `set_name` as the node's label (Role::Label
2550                // routes text to `value` instead), so the test can find the
2551                // leaves by name.
2552                builder.set_role(accesskit::Role::Button);
2553                builder.set_name(self.0);
2554            }
2555        }
2556
2557        #[derive(Debug, Default)]
2558        struct ReorderContainer {
2559            a: Option<WidgetId>,
2560            b: Option<WidgetId>,
2561        }
2562        impl Widget for ReorderContainer {
2563            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2564                let a = ctx.add(NamedLeaf("A"));
2565                let b = ctx.add(NamedLeaf("B"));
2566                self.a = Some(a);
2567                self.b = Some(b);
2568                vec![a, b]
2569            }
2570            fn layout_response(
2571                &self,
2572                proposal: SizeProposal,
2573                _ctx: &LayoutContext,
2574            ) -> crate::widget::LayoutResponse {
2575                proposal.resolve(100.0, 100.0).into()
2576            }
2577            fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2578                // A real role so the container itself isn't dropped as a
2579                // presentational node — we need to inspect its child order.
2580                builder.set_role(accesskit::Role::Group);
2581                builder.set_name("Container");
2582            }
2583            fn children(&self) -> Vec<WidgetId> {
2584                [self.a, self.b].into_iter().flatten().collect()
2585            }
2586            fn accessibility_children(&self) -> Option<Vec<WidgetId>> {
2587                // Reverse of build/layout order.
2588                Some([self.b, self.a].into_iter().flatten().collect())
2589            }
2590        }
2591
2592        let mut tree = WidgetTree::new();
2593        let container = tree.add(ReorderContainer::default());
2594        tree.layout(SizeProposal::exact(100.0, 100.0));
2595        let update = tree.sync_accessibility();
2596
2597        let a_nid = update
2598            .nodes
2599            .iter()
2600            .find(|(_, n)| n.label() == Some("A"))
2601            .map(|(id, _)| *id)
2602            .expect("A node present");
2603        let b_nid = update
2604            .nodes
2605            .iter()
2606            .find(|(_, n)| n.label() == Some("B"))
2607            .map(|(id, _)| *id)
2608            .expect("B node present");
2609        let cnid = crate::accessibility::widget_id_to_node_id(container);
2610        let cnode = update
2611            .nodes
2612            .iter()
2613            .find(|(id, _)| *id == cnid)
2614            .map(|(_, n)| n)
2615            .expect("container node present");
2616        let kids = cnode.children();
2617        let pa = kids.iter().position(|k| *k == a_nid).expect("A is a child");
2618        let pb = kids.iter().position(|k| *k == b_nid).expect("B is a child");
2619        assert!(
2620            pb < pa,
2621            "accessibility_children() must set AT order B-before-A; got {kids:?}"
2622        );
2623    }
2624
2625    // ── the root transform: logical rectangles, physical coordinates ──────
2626    //
2627    // These set `device_scale_factor` to 2.0 explicitly. CI runs at 1.0, where
2628    // a missing or wrong scale is invisible — which is how a whole tree of
2629    // half-size AT rectangles survived this long.
2630
2631    /// The rectangle `accesskit_consumer` computes for the node labelled
2632    /// `label` — the same accumulate-every-ancestor-transform walk every
2633    /// platform adapter performs, so this is literally what an assistive
2634    /// technology is told.
2635    ///
2636    /// Found by label rather than by id because `FullNodeId` cannot be built
2637    /// from outside the consumer crate.
2638    fn consumer_bounding_box(update: &accesskit::TreeUpdate, label: &str) -> accesskit::Rect {
2639        let consumer = accesskit_consumer::Tree::new(update.clone(), false);
2640        let state = consumer.state();
2641        let mut stack = vec![state.root()];
2642        while let Some(node) = stack.pop() {
2643            // A `Role::Label`'s text lives in `value` — `label_comes_from_value`
2644            // is true for exactly that role — so look in both.
2645            let text = node.value().or_else(|| node.label());
2646            if text.as_deref() == Some(label) {
2647                return node.bounding_box().expect("node has bounds");
2648            }
2649            for child in node.children() {
2650                stack.push(child);
2651            }
2652        }
2653        panic!("no node labelled {label:?} in the consumer tree");
2654    }
2655
2656    fn tree_at_scale(scale: f32) -> (WidgetTree, WidgetId) {
2657        let mut tree = WidgetTree::new();
2658        let id = tree.add(FillWidget::new().label("content"));
2659        tree.set_device_scale_factor(scale);
2660        tree.layout(SizeProposal::exact(200.0, 100.0));
2661        (tree, id)
2662    }
2663
2664    #[test]
2665    fn the_root_carries_the_device_scale_as_its_transform() {
2666        let (mut tree, _) = tree_at_scale(2.0);
2667        let update = tree.sync_accessibility();
2668        let root = update
2669            .nodes
2670            .iter()
2671            .find(|(id, _)| *id == crate::accessibility::root_node_id())
2672            .map(|(_, n)| n)
2673            .expect("root node present");
2674        assert_eq!(
2675            root.transform().copied(),
2676            Some(accesskit::Affine::scale(2.0)),
2677            "AccessKit wants physical coordinates; the tree emits logical ones"
2678        );
2679        assert_a11y_tree_valid(&update);
2680    }
2681
2682    #[test]
2683    fn emitted_bounds_stay_logical_at_scale_two() {
2684        // The rule every emitter in the workspace has to obey — this crate's
2685        // walker, teksilo-charts' marks, teksilo-scene's items — is that a
2686        // rectangle handed to AccessKit is the same logical rectangle the
2687        // layout produced. Multiplying by the scale at the emitter would
2688        // double-scale it once the root transform is applied. This is the
2689        // fence for the sites this crate owns; the other two crates' emitters
2690        // are unreachable from here (the dependency runs the other way) and
2691        // are covered by the same statement in `emit_root_transform`'s doc.
2692        let (mut tree, id) = tree_at_scale(2.0);
2693        let update = tree.sync_accessibility();
2694        let layout = tree.bounds(id);
2695        let nid = crate::accessibility::widget_id_to_node_id(id);
2696        let raw = update
2697            .nodes
2698            .iter()
2699            .find(|(n, _)| *n == nid)
2700            .and_then(|(_, n)| n.bounds())
2701            .expect("widget node has bounds");
2702        assert_eq!(
2703            (raw.x0, raw.y0, raw.x1, raw.y1),
2704            (
2705                layout.x as f64,
2706                layout.y as f64,
2707                (layout.x + layout.width) as f64,
2708                (layout.y + layout.height) as f64,
2709            ),
2710            "an emitted rectangle must be the logical layout rectangle, unscaled"
2711        );
2712    }
2713
2714    /// The same fence for an **interactive** node — the one a touch-target
2715    /// change actually reaches.
2716    ///
2717    /// The AccessKit hit test stays exact and is never slop-expanded. A
2718    /// finger's grip outsets belong to the pointer hit path
2719    /// (`WidgetTree::widget_hit_outset` and the miss-only slop pass), not to
2720    /// the AT tree: an assistive technology is told where the control *is*, so
2721    /// inflating that rectangle would put VoiceOver's cursor and Narrator's
2722    /// highlight over ground the control does not own and would overlap its
2723    /// neighbours — and, since AccessKit resolves its own hit tests from these
2724    /// rectangles, would hand a touch-explore probe the wrong control near
2725    /// every edge.
2726    ///
2727    /// A sibling of `emitted_bounds_stay_logical_at_scale_two` rather than an
2728    /// extra assertion in it, because that test's fixture is a bare
2729    /// `FillWidget` advertising no action at all: the natural shape of this
2730    /// mistake — "inflate every node that advertises `Action::Click` so touch
2731    /// targets are easier to hit" — passes it untouched.
2732    #[test]
2733    fn an_interactive_node_emits_its_exact_layout_rectangle() {
2734        let mut tree = WidgetTree::new();
2735        let id = tree.add(ActionWidget);
2736        tree.layout(SizeProposal::exact(200.0, 100.0));
2737        let update = tree.sync_accessibility();
2738        let nid = crate::accessibility::widget_id_to_node_id(id);
2739        let node = update
2740            .nodes
2741            .iter()
2742            .find(|(n, _)| *n == nid)
2743            .map(|(_, n)| n)
2744            .expect("the interactive node is in the update");
2745        assert!(
2746            node.supports_action(accesskit::Action::Click),
2747            "the fixture has to be the clickable shape the rule is about, \
2748             or this test guards nothing"
2749        );
2750        let layout = tree.bounds(id);
2751        assert!(
2752            layout.width > 0.0 && layout.height > 0.0,
2753            "a real, non-degenerate rectangle to compare against: {layout:?}"
2754        );
2755        let raw = node.bounds().expect("the interactive node has bounds");
2756        assert_eq!(
2757            (raw.x0, raw.y0, raw.x1, raw.y1),
2758            (
2759                layout.x as f64,
2760                layout.y as f64,
2761                (layout.x + layout.width) as f64,
2762                (layout.y + layout.height) as f64,
2763            ),
2764            "a clickable node's emitted rectangle is its layout rectangle, \
2765             not an outset one"
2766        );
2767    }
2768
2769    #[test]
2770    fn what_an_assistive_technology_reads_is_the_physical_rectangle() {
2771        // Same widget, two scales: the rectangle the consumer computes — which
2772        // is the one VoiceOver / Narrator / Orca get — doubles, while the
2773        // emitted rectangle above did not.
2774        let (mut one, _) = tree_at_scale(1.0);
2775        let at_one = consumer_bounding_box(&one.sync_accessibility(), "content");
2776        let (mut two, _) = tree_at_scale(2.0);
2777        let at_two = consumer_bounding_box(&two.sync_accessibility(), "content");
2778        assert_eq!(at_two.x0, at_one.x0 * 2.0);
2779        assert_eq!(at_two.y0, at_one.y0 * 2.0);
2780        assert_eq!(at_two.x1, at_one.x1 * 2.0);
2781        assert_eq!(at_two.y1, at_one.y1 * 2.0);
2782        assert!(at_two.x1 > at_two.x0, "a real, non-degenerate rectangle");
2783    }
2784
2785    #[test]
2786    fn a_scale_change_invalidates_the_accessibility_cache() {
2787        // Dragging a window from a 1x to a 2x monitor. A plain relayout does
2788        // not dirty the AT cache, so without the guard in
2789        // `set_device_scale_factor` the second sync would return the cached
2790        // update and report every rectangle at the old display's scale
2791        // for the rest of the window's life.
2792        let (mut tree, _) = tree_at_scale(1.0);
2793        let _ = tree.sync_accessibility();
2794        tree.set_device_scale_factor(2.0);
2795        let update = tree.sync_accessibility();
2796        let root = update
2797            .nodes
2798            .iter()
2799            .find(|(id, _)| *id == crate::accessibility::root_node_id())
2800            .map(|(_, n)| n)
2801            .expect("root node present");
2802        assert_eq!(
2803            root.transform().copied(),
2804            Some(accesskit::Affine::scale(2.0))
2805        );
2806    }
2807
2808    #[test]
2809    fn setting_the_same_scale_does_not_dirty_the_tree() {
2810        // The dirty-mark is behind an equality check, so a set that changes
2811        // nothing costs nothing and — more to the point — cannot make the
2812        // "content changed" version signal move when the content did not.
2813        let (mut tree, _) = tree_at_scale(2.0);
2814        let first = tree.sync_accessibility();
2815        tree.set_device_scale_factor(2.0);
2816        let second = tree.sync_accessibility();
2817        assert_eq!(first, second);
2818    }
2819
2820    // ── the explore-by-touch gate ─────────────────────────────────────────
2821
2822    #[test]
2823    fn explore_by_touch_is_off_by_default() {
2824        let tree = WidgetTree::new();
2825        assert_eq!(tree.explore_by_touch(), crate::ExploreByTouch::Off);
2826        assert_eq!(
2827            tree.screen_reader_state(),
2828            crate::ScreenReaderState::Unknown
2829        );
2830        assert!(!tree.explore_by_touch_active());
2831    }
2832
2833    #[test]
2834    fn auto_follows_the_operating_systems_screen_reader_flag() {
2835        let mut tree = WidgetTree::new();
2836        tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
2837        assert!(!tree.explore_by_touch_active(), "Unknown is not a yes");
2838        tree.set_screen_reader_state(crate::ScreenReaderState::Inactive);
2839        assert!(!tree.explore_by_touch_active());
2840        tree.set_screen_reader_state(crate::ScreenReaderState::Active);
2841        assert!(tree.explore_by_touch_active());
2842    }
2843
2844    #[test]
2845    fn on_ignores_the_screen_reader_flag_and_off_ignores_everything() {
2846        let mut tree = WidgetTree::new();
2847        tree.set_explore_by_touch(crate::ExploreByTouch::On);
2848        assert!(tree.explore_by_touch_active(), "On is the app's own choice");
2849        tree.set_explore_by_touch(crate::ExploreByTouch::Off);
2850        tree.set_screen_reader_state(crate::ScreenReaderState::Active);
2851        assert!(!tree.explore_by_touch_active());
2852    }
2853
2854    #[test]
2855    fn an_attached_accesskit_client_does_not_switch_exploring_on() {
2856        // The whole point of the gate. A magnifier, a voice-control front end,
2857        // a UI-automation inspector and this repository's own automation
2858        // harness all activate an AccessKit adapter. If activation implied a
2859        // screen reader, attaching any of them would turn every touch into a
2860        // probe and make the application untouchable.
2861        let mut tree = WidgetTree::new();
2862        tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
2863        tree.set_at_client_attached(true);
2864        assert!(tree.at_client_attached());
2865        assert_eq!(
2866            tree.screen_reader_state(),
2867            crate::ScreenReaderState::Unknown,
2868            "attaching says nothing about screen readers"
2869        );
2870        assert!(!tree.explore_by_touch_active());
2871    }
2872
2873    #[test]
2874    fn touch_still_reaches_a_handler_with_an_accesskit_client_attached() {
2875        // The acceptance criterion, as a behaviour rather than a flag: a tap
2876        // must arrive at its handler at the `Off` default with an assistive
2877        // technology attached. Nothing gates the pointer path on
2878        // `explore_by_touch_active()` today; this is the fence for whoever
2879        // wires touch-as-probe later.
2880        use crate::Modifiers;
2881        use crate::pointer::{
2882            BackendDeviceKey, EventTime, PointerIdAllocator, PointerInfo, PointerPhase,
2883            PointerSample,
2884        };
2885        use crate::widget_builder::WidgetBuilder;
2886        use std::cell::Cell;
2887        use std::rc::Rc;
2888
2889        let taps = Rc::new(Cell::new(0u32));
2890        let seen = Rc::clone(&taps);
2891        let mut tree = WidgetTree::new();
2892        let id = tree.add(
2893            FillWidget::new()
2894                .label("target")
2895                .on_tap(move |_e, _ctx| seen.set(seen.get() + 1)),
2896        );
2897        tree.layout(SizeProposal::exact(200.0, 100.0));
2898        tree.set_at_client_attached(true);
2899        assert!(!tree.explore_by_touch_active());
2900
2901        let at = Point::new(50.0, 50.0);
2902        let pid = PointerIdAllocator::global().begin(BackendDeviceKey::new(0x0A11), 1);
2903        let contact = |phase| PointerSample {
2904            pointer: PointerInfo::touch(pid, EventTime::ZERO),
2905            phase,
2906            position: at,
2907            button: None,
2908            modifiers: Modifiers::NONE,
2909            coalesced: Vec::new(),
2910        };
2911        tree.dispatch_pointer(contact(PointerPhase::Down));
2912        tree.dispatch_pointer(contact(PointerPhase::Up));
2913        assert!(tree.bounds(id).width > 0.0);
2914        assert_eq!(taps.get(), 1, "a touch tap must still activate its handler");
2915    }
2916
2917    #[test]
2918    fn a_detaching_client_is_evidence_that_no_screen_reader_is_reading() {
2919        // The asymmetry: attaching proves nothing, detaching proves something.
2920        // An `Auto` window stops exploring the moment the last client goes,
2921        // without waiting for the next OS query.
2922        let mut tree = WidgetTree::new();
2923        tree.set_explore_by_touch(crate::ExploreByTouch::Auto);
2924        tree.set_screen_reader_state(crate::ScreenReaderState::Active);
2925        tree.set_at_client_attached(true);
2926        assert!(tree.explore_by_touch_active());
2927        tree.set_at_client_attached(false);
2928        assert_eq!(
2929            tree.screen_reader_state(),
2930            crate::ScreenReaderState::Inactive
2931        );
2932        assert!(!tree.explore_by_touch_active());
2933    }
2934
2935    #[test]
2936    fn a_repeated_detach_notice_does_not_clobber_a_fresh_os_reading() {
2937        // `teksilo-app` pushes the attachment flag every frame. Only the
2938        // true→false edge counts: if every `false` forced `Inactive`, an OS
2939        // query that found a screen reader would be undone on the next frame.
2940        let mut tree = WidgetTree::new();
2941        tree.set_at_client_attached(false);
2942        tree.set_screen_reader_state(crate::ScreenReaderState::Active);
2943        tree.set_at_client_attached(false);
2944        assert_eq!(tree.screen_reader_state(), crate::ScreenReaderState::Active);
2945    }
2946
2947    // ── announcements ─────────────────────────────────────────────────────
2948
2949    #[test]
2950    fn a_density_switch_announces_once_in_the_applications_own_words() {
2951        use teksilo_tokens::TargetDensity;
2952        let mut tree = tree_with_one_widget();
2953        tree.set_density_announcement(Some(std::rc::Rc::new(|d: TargetDensity| {
2954            format!("layout {d:?}")
2955        })));
2956        let _ = tree.sync_accessibility();
2957
2958        tree.set_input_density(TargetDensity::Comfortable);
2959        let update = tree.sync_accessibility();
2960        assert_eq!(
2961            announced_by_consumer(&update),
2962            vec![("layout Comfortable".to_string(), accesskit::Live::Polite)]
2963        );
2964
2965        // Once per *switch*: setting the density it already has says nothing.
2966        let _ = tree.sync_accessibility(); // let the announcer retract
2967        tree.set_input_density(TargetDensity::Comfortable);
2968        let update = tree.sync_accessibility();
2969        assert_eq!(announced_by_consumer(&update), Vec::new());
2970    }
2971
2972    #[test]
2973    fn a_density_switch_is_silent_without_a_registered_wording() {
2974        // The framework cannot word this itself: `teksilo-i18n` depends on this
2975        // crate, so an English sentence is all core could hardcode, and an
2976        // English sentence spoken into a French screen reader is worse than
2977        // silence.
2978        use teksilo_tokens::TargetDensity;
2979        let mut tree = tree_with_one_widget();
2980        let _ = tree.sync_accessibility();
2981        tree.set_input_density(TargetDensity::Comfortable);
2982        let update = tree.sync_accessibility();
2983        assert_eq!(announced_by_consumer(&update), Vec::new());
2984    }
2985
2986    #[test]
2987    fn an_announcement_stays_quiet_when_the_widget_already_speaks() {
2988        let mut tree = WidgetTree::new();
2989        use crate::widget_builder::WidgetBuilder;
2990        let widget = tree.add(
2991            FillWidget::new()
2992                .label("Saved")
2993                .access_live(accesskit::Live::Polite),
2994        );
2995        tree.layout(SizeProposal::exact(200.0, 100.0));
2996        // Build the tree the check reads.
2997        let _ = tree.sync_accessibility();
2998
2999        assert!(
3000            !tree.announce_unless_widget_speaks(widget, "Saved"),
3001            "a widget with its own live region must not be doubled"
3002        );
3003        let update = tree.sync_accessibility();
3004        // One live node in the filtered tree — the widget's own. If the
3005        // framework had queued its copy, its announcer node would have entered
3006        // the filtered tree beside it and the user would hear "Saved" twice.
3007        let live = live_nodes_in_filtered_tree(&update);
3008        assert_eq!(live.len(), 1, "exactly one voice, not two: {live:?}");
3009    }
3010
3011    #[test]
3012    fn an_announcement_goes_ahead_when_the_widget_is_silent() {
3013        let mut tree = WidgetTree::new();
3014        let widget = tree.add(FillWidget::new().label("Row"));
3015        tree.layout(SizeProposal::exact(200.0, 100.0));
3016        let _ = tree.sync_accessibility();
3017
3018        assert!(tree.announce_unless_widget_speaks(widget, "Moved to 3 of 12"));
3019        let update = tree.sync_accessibility();
3020        assert_eq!(
3021            announced_by_consumer(&update),
3022            vec![("Moved to 3 of 12".to_string(), accesskit::Live::Polite)]
3023        );
3024    }
3025
3026    #[test]
3027    fn a_live_region_with_no_text_does_not_count_as_speaking() {
3028        // A live region that is present but empty announces nothing on any
3029        // platform, so it must not silence the framework's message.
3030        let mut tree = WidgetTree::new();
3031        use crate::widget_builder::WidgetBuilder;
3032        let widget = tree.add(FillWidget::new().access_live(accesskit::Live::Polite));
3033        tree.layout(SizeProposal::exact(200.0, 100.0));
3034        let _ = tree.sync_accessibility();
3035        assert!(tree.announce_unless_widget_speaks(widget, "Something happened"));
3036    }
3037
3038    /// A widget that emits a live-region **synthetic** child, the way
3039    /// `teksilo-scene` marks a scene item as a live region.
3040    #[derive(Debug)]
3041    struct SpeakingSynthetic;
3042
3043    impl crate::widget::Widget for SpeakingSynthetic {
3044        fn layout_response(
3045            &self,
3046            proposal: SizeProposal,
3047            _ctx: &crate::widget::LayoutContext,
3048        ) -> crate::widget::LayoutResponse {
3049            proposal.resolve(0.0, 0.0).into()
3050        }
3051
3052        fn accessibility(&self, builder: &mut AccessNodeBuilder) {
3053            builder.set_role(accesskit::Role::Group);
3054            let child = builder.push_annotation_child(1, "2 items selected");
3055            builder.with_collected_node(child, |node| node.set_live(accesskit::Live::Polite));
3056        }
3057    }
3058
3059    #[test]
3060    fn a_live_synthetic_child_counts_as_the_widget_speaking() {
3061        // A scene can mark one of its lightweight items as a live region. The
3062        // item has no widget of its own, so the check has to resolve it back
3063        // to its owner through the synthetic-parent map — the same map the
3064        // AccessKit action router uses.
3065        let mut tree = WidgetTree::new();
3066        let widget = tree.add(SpeakingSynthetic);
3067        tree.layout(SizeProposal::exact(200.0, 100.0));
3068        let update = tree.sync_accessibility();
3069        assert!(
3070            update
3071                .nodes
3072                .iter()
3073                .any(|(id, _)| crate::accessibility::is_synthetic(*id)),
3074            "the fixture must actually emit a synthetic child"
3075        );
3076        assert!(!tree.announce_unless_widget_speaks(widget, "2 items selected"));
3077    }
3078
3079    #[test]
3080    fn a_descendants_live_region_silences_its_ancestor_too() {
3081        // The check is over the whole subtree: a composite whose inner status
3082        // line speaks is a composite that speaks.
3083        let mut tree = WidgetTree::new();
3084        let container = tree.add(StackWidget::new());
3085        use crate::widget_builder::WidgetBuilder;
3086        let inner = FillWidget::new()
3087            .label("3 results")
3088            .access_live(accesskit::Live::Polite);
3089        tree.add_child(container, inner);
3090        tree.layout(SizeProposal::exact(200.0, 100.0));
3091        let _ = tree.sync_accessibility();
3092        assert!(!tree.announce_unless_widget_speaks(container, "3 results"));
3093    }
3094}