Skip to main content

cranpose_app_shell/
accessibility_audit.rs

1//! The checks a screen reader user fails a screen on, run over a placed
2//! semantics tree so a test catches them before a person does.
3
4use std::fmt;
5
6use cranpose_foundation::SemanticsWidgetRole;
7
8use crate::placed_semantics::PlacedSemanticsNode;
9
10/// The smallest target WCAG 2.5.8 accepts, in points. Material asks for 48
11/// and Apple for 44; `Modifier::minimum_interactive_component_size()` gives
12/// a small control the 48.
13pub const MINIMUM_TARGET_SIZE: f32 = 24.0;
14
15/// One kind of thing a reader user trips over.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
17pub enum AccessibilityIssueKind {
18    /// A control a reader can press or type into has nothing to say.
19    NoName,
20    /// Two controls of one role say the same name, so a reader user cannot
21    /// tell them apart.
22    SameName,
23    /// A control's target is under [`MINIMUM_TARGET_SIZE`] on a side.
24    SmallTarget,
25    /// A control sits fully above the one a reader visits before it, with
26    /// no `traversal_index` to say so on purpose.
27    OutOfOrder,
28    /// No node names the screen, so a reader says nothing on arrival.
29    NoPaneTitle,
30    /// A picture a reader stops on with no words for it.
31    UnnamedImage,
32}
33
34impl AccessibilityIssueKind {
35    /// What fixes the issue.
36    pub fn advice(self) -> &'static str {
37        match self {
38            Self::NoName => "give it Modifier::content_description(...), or put a Text inside it",
39            Self::SameName => {
40                "say what each one acts on: \"Delete Milk\" and \"Delete Bread\", not \"Delete\" twice"
41            }
42            Self::SmallTarget => {
43                "add Modifier::minimum_interactive_component_size(), or make the control larger"
44            }
45            Self::OutOfOrder => {
46                "lay the controls out in reading order, or set Modifier::traversal_index(...)"
47            }
48            Self::NoPaneTitle => "put Modifier::pane_title(\"...\") on the screen's root",
49            Self::UnnamedImage => {
50                "give the image a content description, or pass None so a reader skips it"
51            }
52        }
53    }
54}
55
56/// One thing a reader user trips over on a screen.
57#[derive(Clone, Debug, PartialEq)]
58pub struct AccessibilityIssue {
59    pub kind: AccessibilityIssueKind,
60    /// The control the issue is about: its role and its spoken name.
61    pub control: String,
62    /// What is wrong with it, when the kind alone does not say.
63    pub detail: String,
64}
65
66impl fmt::Display for AccessibilityIssue {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(f, "{:?}: {}{}", self.kind, self.control, self.detail)
69    }
70}
71
72/// Every issue on the screen, in reading order.
73pub fn audit_accessibility(root: &PlacedSemanticsNode) -> Vec<AccessibilityIssue> {
74    let mut visible = Vec::new();
75    collect_visible(root, &mut visible);
76
77    let mut issues = Vec::new();
78    for node in &visible {
79        let name = spoken_name(node);
80        if is_control(node) {
81            check_control(node, &name, &mut issues);
82        } else if node.widget_role == Some(SemanticsWidgetRole::Image) && name.is_empty() {
83            issues.push(issue(
84                AccessibilityIssueKind::UnnamedImage,
85                node,
86                &name,
87                &where_it_is(node),
88            ));
89        }
90        check_order(node, &mut issues);
91    }
92    check_same_names(&visible, &mut issues);
93    if !visible.iter().any(|node| node.pane_title.is_some()) {
94        issues.push(AccessibilityIssue {
95            kind: AccessibilityIssueKind::NoPaneTitle,
96            control: "the screen".to_string(),
97            detail: String::new(),
98        });
99    }
100    issues
101}
102
103/// Fails the test with every issue on the screen and what fixes each kind.
104pub fn assert_accessible(root: &PlacedSemanticsNode) {
105    let issues = audit_accessibility(root);
106    if issues.is_empty() {
107        return;
108    }
109    let mut report = format!("{} accessibility issue(s):\n", issues.len());
110    for issue in &issues {
111        report.push_str(&format!("  {issue}\n"));
112    }
113    let mut kinds: Vec<AccessibilityIssueKind> = issues.iter().map(|issue| issue.kind).collect();
114    kinds.dedup();
115    kinds.sort_by_key(|kind| *kind as u8);
116    kinds.dedup();
117    for kind in kinds {
118        report.push_str(&format!("  {kind:?}: {}\n", kind.advice()));
119    }
120    panic!("{report}");
121}
122
123/// The name a reader speaks for a node: its own label, else the title it
124/// gives the screen, else the words under it.
125pub fn spoken_name(node: &PlacedSemanticsNode) -> String {
126    if let Some(label) = node
127        .label
128        .as_deref()
129        .filter(|label| !label.trim().is_empty())
130    {
131        return label.trim().to_string();
132    }
133    if let Some(title) = node
134        .pane_title
135        .as_deref()
136        .filter(|title| !title.trim().is_empty())
137    {
138        return title.trim().to_string();
139    }
140    let mut words = Vec::new();
141    for child in &node.children {
142        if child.hidden {
143            continue;
144        }
145        let name = spoken_name(child);
146        if !name.is_empty() {
147            words.push(name);
148        }
149    }
150    words.join(" ")
151}
152
153fn collect_visible<'a>(node: &'a PlacedSemanticsNode, out: &mut Vec<&'a PlacedSemanticsNode>) {
154    if node.hidden {
155        return;
156    }
157    out.push(node);
158    for child in &node.children {
159        collect_visible(child, out);
160    }
161}
162
163fn is_control(node: &PlacedSemanticsNode) -> bool {
164    node.clickable || node.editable_text || node.focusable
165}
166
167fn holds_control(node: &PlacedSemanticsNode) -> bool {
168    !node.hidden && (is_control(node) || node.children.iter().any(holds_control))
169}
170
171fn check_control(node: &PlacedSemanticsNode, name: &str, issues: &mut Vec<AccessibilityIssue>) {
172    if name.is_empty() {
173        issues.push(issue(
174            AccessibilityIssueKind::NoName,
175            node,
176            name,
177            &where_it_is(node),
178        ));
179    }
180    let target = node.target_bounds();
181    if target.width > 0.0
182        && target.height > 0.0
183        && (target.width < MINIMUM_TARGET_SIZE || target.height < MINIMUM_TARGET_SIZE)
184    {
185        let detail = format!(
186            " is {}x{} points",
187            target.width.round(),
188            target.height.round()
189        );
190        issues.push(issue(
191            AccessibilityIssueKind::SmallTarget,
192            node,
193            name,
194            &detail,
195        ));
196    }
197}
198
199fn check_order(parent: &PlacedSemanticsNode, issues: &mut Vec<AccessibilityIssue>) {
200    let in_order: Vec<&PlacedSemanticsNode> = parent
201        .children
202        .iter()
203        .filter(|child| {
204            holds_control(child)
205                && child.traversal_index == 0.0
206                && child.layout_bounds.width > 0.0
207                && child.layout_bounds.height > 0.0
208        })
209        .collect();
210    for pair in in_order.windows(2) {
211        let (before, after) = (pair[0], pair[1]);
212        let after_bottom = after.layout_bounds.y + after.layout_bounds.height;
213        if after_bottom <= before.layout_bounds.y {
214            let detail = format!(" sits above {:?}", spoken_name(before));
215            issues.push(issue(
216                AccessibilityIssueKind::OutOfOrder,
217                after,
218                &spoken_name(after),
219                &detail,
220            ));
221        }
222    }
223}
224
225/// Two controls of one role with one name. Rows of a list at different
226/// places are apart: a reader speaks their place.
227fn check_same_names(visible: &[&PlacedSemanticsNode], issues: &mut Vec<AccessibilityIssue>) {
228    let mut seen: Vec<(String, Option<SemanticsWidgetRole>, Option<usize>, usize)> = Vec::new();
229    for node in visible.iter().filter(|node| is_control(node)) {
230        let name = spoken_name(node);
231        if name.is_empty() {
232            continue;
233        }
234        match seen.iter_mut().find(|(seen_name, role, position, _)| {
235            *seen_name == name && *role == node.widget_role && *position == node.list_position
236        }) {
237            Some(entry) => entry.3 += 1,
238            None => seen.push((name, node.widget_role, node.list_position, 1)),
239        }
240    }
241    for (name, role, _, count) in seen.into_iter().filter(|(_, _, _, count)| *count > 1) {
242        issues.push(AccessibilityIssue {
243            kind: AccessibilityIssueKind::SameName,
244            control: format!("{} {name:?}", role_word(role)),
245            detail: format!(" appears {count} times"),
246        });
247    }
248}
249
250fn issue(
251    kind: AccessibilityIssueKind,
252    node: &PlacedSemanticsNode,
253    name: &str,
254    detail: &str,
255) -> AccessibilityIssue {
256    AccessibilityIssue {
257        kind,
258        control: format!("{} {name:?}", role_word(node.widget_role)),
259        detail: detail.to_string(),
260    }
261}
262
263fn where_it_is(node: &PlacedSemanticsNode) -> String {
264    let bounds = node.layout_bounds;
265    format!(
266        " is {}x{} points at ({}, {})",
267        bounds.width.round(),
268        bounds.height.round(),
269        bounds.x.round(),
270        bounds.y.round()
271    )
272}
273
274fn role_word(role: Option<SemanticsWidgetRole>) -> String {
275    match role {
276        Some(role) => format!("{role:?}").to_lowercase(),
277        None => "control".to_string(),
278    }
279}
280
281/// An issue a test leaves as it is: the screen it is on, `*` for every
282/// screen; the start of the issue line; the reason it stays.
283pub type KnownIssue = (&'static str, &'static str, &'static str);
284
285/// Compares the issues found on `screen` with the list a test keeps and
286/// reports what changed: the lines that are new, and the listed issues that
287/// went away. A test fails on either, so the list only shrinks. An issue
288/// matches a listed one when its line starts with the listed text; a `*`
289/// entry matches on every screen and is never reported as gone.
290pub fn audit_changes(screen: &str, issues: &[String], known: &[KnownIssue]) -> Result<(), String> {
291    let listed = |issue: &str| {
292        known
293            .iter()
294            .any(|(on, text, _)| (*on == "*" || *on == screen) && issue.starts_with(text))
295    };
296    let new: Vec<&str> = issues
297        .iter()
298        .map(String::as_str)
299        .filter(|issue| !listed(issue))
300        .collect();
301    let gone: Vec<&str> = known
302        .iter()
303        .filter(|(on, text, _)| {
304            *on == screen && !issues.iter().any(|issue| issue.starts_with(text))
305        })
306        .map(|(_, text, _)| *text)
307        .collect();
308    if new.is_empty() && gone.is_empty() {
309        return Ok(());
310    }
311    let mut report = String::new();
312    if !new.is_empty() {
313        report.push_str(&format!(
314            "new accessibility issues on {screen}:\n  {}\n",
315            new.join("\n  ")
316        ));
317    }
318    if !gone.is_empty() {
319        report.push_str(&format!(
320            "issues listed for {screen} went away; take them off the list:\n  {}\n",
321            gone.join("\n  ")
322        ));
323    }
324    Err(report)
325}
326
327#[cfg(test)]
328mod tests {
329    use cranpose_ui::{Rect, SemanticsRole};
330
331    use super::*;
332
333    fn node(label: Option<&str>, rect: Rect) -> PlacedSemanticsNode {
334        PlacedSemanticsNode {
335            node_id: 0,
336            role: SemanticsRole::Unknown,
337            widget_role: None,
338            label: label.map(str::to_string),
339            state_description: None,
340            clickable: false,
341            interactive: false,
342            toggled: None,
343            selected: None,
344            enabled: true,
345            editable_text: false,
346            focusable: false,
347            hidden: false,
348            pane_title: None,
349            traversal_index: 0.0,
350            list_position: None,
351            layout_bounds: rect,
352            touch_bounds: None,
353            children: Vec::new(),
354        }
355    }
356
357    fn button(label: Option<&str>, rect: Rect) -> PlacedSemanticsNode {
358        PlacedSemanticsNode {
359            widget_role: Some(SemanticsWidgetRole::Button),
360            clickable: true,
361            focusable: true,
362            ..node(label, rect)
363        }
364    }
365
366    fn rect(x: f32, y: f32, width: f32, height: f32) -> Rect {
367        Rect {
368            x,
369            y,
370            width,
371            height,
372        }
373    }
374
375    fn screen(children: Vec<PlacedSemanticsNode>) -> PlacedSemanticsNode {
376        PlacedSemanticsNode {
377            pane_title: Some("Library".to_string()),
378            children,
379            ..node(None, rect(0.0, 0.0, 400.0, 800.0))
380        }
381    }
382
383    fn kinds(root: &PlacedSemanticsNode) -> Vec<AccessibilityIssueKind> {
384        audit_accessibility(root)
385            .into_iter()
386            .map(|issue| issue.kind)
387            .collect()
388    }
389
390    #[test]
391    fn a_named_screen_with_named_large_controls_in_order_passes() {
392        let root = screen(vec![
393            button(Some("Send"), rect(0.0, 0.0, 120.0, 48.0)),
394            button(Some("Cancel"), rect(0.0, 60.0, 120.0, 48.0)),
395        ]);
396        assert!(audit_accessibility(&root).is_empty());
397        assert_accessible(&root);
398    }
399
400    #[test]
401    fn a_control_with_nothing_to_say_is_named_as_such() {
402        let root = screen(vec![button(None, rect(0.0, 0.0, 48.0, 48.0))]);
403        let issues = audit_accessibility(&root);
404        assert_eq!(issues.len(), 1);
405        assert_eq!(
406            issues[0].to_string(),
407            "NoName: button \"\" is 48x48 points at (0, 0)"
408        );
409    }
410
411    #[test]
412    fn the_words_inside_a_control_are_its_name() {
413        let mut send = button(None, rect(0.0, 0.0, 120.0, 48.0));
414        send.children.push(PlacedSemanticsNode {
415            role: SemanticsRole::Text {
416                value: "Send".to_string(),
417            },
418            ..node(Some("Send"), rect(8.0, 8.0, 40.0, 20.0))
419        });
420        assert!(audit_accessibility(&screen(vec![send])).is_empty());
421    }
422
423    #[test]
424    fn two_controls_with_one_name_and_one_role_are_reported_once() {
425        let root = screen(vec![
426            button(Some("Delete"), rect(0.0, 0.0, 120.0, 48.0)),
427            button(Some("Delete"), rect(0.0, 60.0, 120.0, 48.0)),
428        ]);
429        let issues = audit_accessibility(&root);
430        assert_eq!(issues.len(), 1);
431        assert_eq!(
432            issues[0].to_string(),
433            "SameName: button \"Delete\" appears 2 times"
434        );
435    }
436
437    #[test]
438    fn a_small_target_says_its_size() {
439        let root = screen(vec![button(Some("Close"), rect(0.0, 0.0, 16.0, 16.0))]);
440        let issues = audit_accessibility(&root);
441        assert_eq!(
442            issues[0].to_string(),
443            "SmallTarget: button \"Close\" is 16x16 points"
444        );
445    }
446
447    #[test]
448    fn a_touch_target_counts_over_the_drawn_size() {
449        let mut close = button(Some("Close"), rect(0.0, 0.0, 16.0, 16.0));
450        close.touch_bounds = Some(rect(-16.0, -16.0, 48.0, 48.0));
451        assert!(audit_accessibility(&screen(vec![close])).is_empty());
452    }
453
454    #[test]
455    fn a_control_placed_above_the_one_before_it_is_out_of_order() {
456        let root = screen(vec![
457            button(Some("Second"), rect(0.0, 100.0, 120.0, 48.0)),
458            button(Some("First"), rect(0.0, 0.0, 120.0, 48.0)),
459        ]);
460        let issues = audit_accessibility(&root);
461        assert_eq!(kinds(&root), vec![AccessibilityIssueKind::OutOfOrder]);
462        assert_eq!(
463            issues[0].to_string(),
464            "OutOfOrder: button \"First\" sits above \"Second\""
465        );
466        let mut on_purpose = root.clone();
467        on_purpose.children[1].traversal_index = -1.0;
468        assert!(audit_accessibility(&on_purpose).is_empty());
469    }
470
471    #[test]
472    fn a_screen_without_a_title_and_a_picture_without_words_are_reported() {
473        let mut root = node(None, rect(0.0, 0.0, 400.0, 800.0));
474        root.children.push(PlacedSemanticsNode {
475            widget_role: Some(SemanticsWidgetRole::Image),
476            ..node(None, rect(0.0, 0.0, 64.0, 64.0))
477        });
478        assert_eq!(
479            kinds(&root),
480            vec![
481                AccessibilityIssueKind::UnnamedImage,
482                AccessibilityIssueKind::NoPaneTitle
483            ]
484        );
485    }
486
487    #[test]
488    fn a_hidden_control_is_left_alone() {
489        let root = screen(vec![PlacedSemanticsNode {
490            hidden: true,
491            ..button(None, rect(0.0, 0.0, 10.0, 10.0))
492        }]);
493        assert!(audit_accessibility(&root).is_empty());
494    }
495
496    #[test]
497    #[should_panic(expected = "NoName: button \"\"")]
498    fn the_assertion_names_every_issue_and_the_fix() {
499        assert_accessible(&screen(vec![button(None, rect(0.0, 0.0, 48.0, 48.0))]));
500    }
501
502    #[test]
503    fn a_new_issue_and_a_listed_issue_that_went_away_both_fail_the_comparison() {
504        let known: &[KnownIssue] = &[
505            (
506                "library",
507                "SmallTarget: control \"Pill\"",
508                "sits over a tab",
509            ),
510            (
511                "*",
512                "SameName: tab \"Apps\"",
513                "two bars show one set of tabs",
514            ),
515        ];
516        let issues = vec![
517            "SmallTarget: control \"Pill\" is 79x17 points".to_string(),
518            "SameName: tab \"Apps\" appears 2 times".to_string(),
519        ];
520        assert_eq!(audit_changes("library", &issues, known), Ok(()));
521        assert_eq!(
522            audit_changes("settings", &issues[1..], known),
523            Ok(()),
524            "a * entry matches on every screen"
525        );
526        let with_new = [
527            issues.clone(),
528            vec!["NoName: control \"\" is 40x40 points at (1, 2)".to_string()],
529        ]
530        .concat();
531        let report = audit_changes("library", &with_new, known).unwrap_err();
532        assert!(report.contains("new accessibility issues on library"));
533        assert!(report.contains("NoName"));
534        let report = audit_changes("library", &issues[1..], known).unwrap_err();
535        assert!(report.contains("went away"));
536        assert!(report.contains("SmallTarget"));
537        assert_eq!(
538            audit_changes("settings", &[], known),
539            Ok(()),
540            "a * entry is never reported as gone"
541        );
542    }
543
544    #[test]
545    fn rows_of_a_list_with_one_name_are_apart_when_each_has_its_place() {
546        let mut list = screen(vec![
547            button(Some("Scan"), rect(0.0, 0.0, 200.0, 40.0)),
548            button(Some("Scan"), rect(0.0, 50.0, 200.0, 40.0)),
549        ]);
550        let same_names = |root: &PlacedSemanticsNode| {
551            audit_accessibility(root)
552                .iter()
553                .any(|issue| issue.kind == AccessibilityIssueKind::SameName)
554        };
555        assert!(same_names(&list), "two buttons with one name and no place");
556        for (row, position) in list.children.iter_mut().zip(1..) {
557            row.list_position = Some(position);
558        }
559        assert!(!same_names(&list), "the same two as rows 1 and 2 of a list");
560    }
561
562    #[test]
563    fn a_row_wrapped_by_its_list_still_gets_its_place() {
564        let wrapped = |y: f32| PlacedSemanticsNode {
565            children: vec![button(Some("Scan"), rect(0.0, y, 200.0, 40.0))],
566            ..node(None, rect(0.0, y, 200.0, 40.0))
567        };
568        let mut list = screen(vec![wrapped(0.0), wrapped(50.0)]);
569        for (row, position) in list.children.iter_mut().zip(1..) {
570            crate::placed_semantics::place_row(row, position);
571        }
572        let issues = audit_accessibility(&list);
573        assert!(
574            !issues
575                .iter()
576                .any(|issue| issue.kind == AccessibilityIssueKind::SameName),
577            "the buttons under the wrappers carry places 1 and 2: {issues:?}"
578        );
579    }
580
581    #[test]
582    fn a_control_inside_a_row_carries_the_place_of_the_row() {
583        let row = |y: f32| PlacedSemanticsNode {
584            children: vec![
585                button(Some("page thumbnail"), rect(0.0, y, 40.0, 40.0)),
586                button(Some("More"), rect(160.0, y, 40.0, 40.0)),
587            ],
588            ..node(None, rect(0.0, y, 200.0, 40.0))
589        };
590        let mut list = screen(vec![row(0.0), row(50.0)]);
591        for (row, position) in list.children.iter_mut().zip(1..) {
592            crate::placed_semantics::place_row(row, position);
593        }
594        let issues = audit_accessibility(&list);
595        assert!(
596            !issues
597                .iter()
598                .any(|issue| issue.kind == AccessibilityIssueKind::SameName),
599            "the thumbnails and the More buttons sit in rows 1 and 2: {issues:?}"
600        );
601    }
602}