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