Skip to main content

rightkit_qa/
a11y.rs

1//! Accessibility evidence for UI scenarios: text contrast (WCAG 2.x), the keyboard-focus indicator,
2//! and role/name assertions over the control server's `ax` nodes.
3//!
4//! The page-side reads are small JS snippets ([`contrast_probe_js`], [`focus_probe_js`]) sent through
5//! the control server's `eval`. Everything that decides a verdict is pure Rust over the JSON they
6//! return, so it is unit-tested without an app: colour parsing, background compositing, the contrast
7//! ratio, the focus-indicator verdict and the role/name matcher.
8use crate::util::{err, Error, Result};
9use serde_json::{json, Map, Value};
10
11// ------------------------------------------------------------------- colour
12
13/// A colour in sRGB: channels 0..=255, alpha 0..=1.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Rgba {
16    pub r: f64,
17    pub g: f64,
18    pub b: f64,
19    pub a: f64,
20}
21
22impl Rgba {
23    pub const WHITE: Rgba = Rgba {
24        r: 255.0,
25        g: 255.0,
26        b: 255.0,
27        a: 1.0,
28    };
29    pub const TRANSPARENT: Rgba = Rgba {
30        r: 0.0,
31        g: 0.0,
32        b: 0.0,
33        a: 0.0,
34    };
35
36    /// `self` painted over `under` (source-over). The result's alpha is the union of both.
37    pub fn over(self, under: Rgba) -> Rgba {
38        let a = self.a + under.a * (1.0 - self.a);
39        if a <= 0.0 {
40            return Rgba::TRANSPARENT;
41        }
42        let mix = |f: f64, b: f64| (f * self.a + b * under.a * (1.0 - self.a)) / a;
43        Rgba {
44            r: mix(self.r, under.r),
45            g: mix(self.g, under.g),
46            b: mix(self.b, under.b),
47            a,
48        }
49    }
50
51    pub fn hex(&self) -> String {
52        format!(
53            "#{:02x}{:02x}{:02x}",
54            self.r.round() as u8,
55            self.g.round() as u8,
56            self.b.round() as u8
57        )
58    }
59}
60
61fn channel(token: &str, scale: f64) -> Option<f64> {
62    let t = token.trim();
63    if let Some(p) = t.strip_suffix('%') {
64        return p
65            .trim()
66            .parse::<f64>()
67            .ok()
68            .map(|v| (v / 100.0 * scale).clamp(0.0, scale));
69    }
70    t.parse::<f64>().ok().map(|v| v.clamp(0.0, scale))
71}
72
73fn alpha(token: &str) -> Option<f64> {
74    let t = token.trim();
75    if let Some(p) = t.strip_suffix('%') {
76        return p
77            .trim()
78            .parse::<f64>()
79            .ok()
80            .map(|v| (v / 100.0).clamp(0.0, 1.0));
81    }
82    t.parse::<f64>().ok().map(|v| v.clamp(0.0, 1.0))
83}
84
85/// Parse a CSS colour as the browser reports it from `getComputedStyle`: `rgb()`/`rgba()` (comma or
86/// space syntax, optional `/ alpha`), `color(srgb r g b / a)`, `#rgb`, `#rgba`, `#rrggbb`,
87/// `#rrggbbaa` and `transparent`. Anything else (named colours, `oklch`, ...) is `None`.
88pub fn parse_css_color(text: &str) -> Option<Rgba> {
89    let t = text.trim().to_ascii_lowercase();
90    if t == "transparent" {
91        return Some(Rgba::TRANSPARENT);
92    }
93    if let Some(hex) = t.strip_prefix('#') {
94        if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
95            return None;
96        }
97        let nib = |i: usize| {
98            u8::from_str_radix(&hex[i..i + 1], 16)
99                .ok()
100                .map(|v| f64::from(v * 17))
101        };
102        let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok().map(f64::from);
103        return match hex.len() {
104            3 => Some(Rgba {
105                r: nib(0)?,
106                g: nib(1)?,
107                b: nib(2)?,
108                a: 1.0,
109            }),
110            4 => Some(Rgba {
111                r: nib(0)?,
112                g: nib(1)?,
113                b: nib(2)?,
114                a: nib(3)? / 255.0,
115            }),
116            6 => Some(Rgba {
117                r: byte(0)?,
118                g: byte(2)?,
119                b: byte(4)?,
120                a: 1.0,
121            }),
122            8 => Some(Rgba {
123                r: byte(0)?,
124                g: byte(2)?,
125                b: byte(4)?,
126                a: byte(6)? / 255.0,
127            }),
128            _ => None,
129        };
130    }
131    let open = t.find('(')?;
132    let close = t.rfind(')')?;
133    let (func, args) = (t[..open].trim(), &t[open + 1..close]);
134    let (main, slash_alpha) = match args.split_once('/') {
135        Some((m, a)) => (m, Some(a)),
136        None => (args, None),
137    };
138    let parts: Vec<&str> = main
139        .split(|c: char| c == ',' || c.is_whitespace())
140        .filter(|s| !s.is_empty())
141        .collect();
142    match func {
143        "rgb" | "rgba" => {
144            let (rgb, comma_alpha) = match parts.len() {
145                3 => (&parts[..3], None),
146                4 => (&parts[..3], Some(parts[3])),
147                _ => return None,
148            };
149            let a = match (slash_alpha, comma_alpha) {
150                (Some(a), None) | (None, Some(a)) => alpha(a)?,
151                (None, None) => 1.0,
152                _ => return None,
153            };
154            Some(Rgba {
155                r: channel(rgb[0], 255.0)?,
156                g: channel(rgb[1], 255.0)?,
157                b: channel(rgb[2], 255.0)?,
158                a,
159            })
160        }
161        "color" => {
162            if parts.len() != 4 || parts[0] != "srgb" {
163                return None;
164            }
165            let a = slash_alpha.map(alpha).unwrap_or(Some(1.0))?;
166            Some(Rgba {
167                r: channel(parts[1], 1.0)? * 255.0,
168                g: channel(parts[2], 1.0)? * 255.0,
169                b: channel(parts[3], 1.0)? * 255.0,
170                a,
171            })
172        }
173        _ => None,
174    }
175}
176
177/// WCAG relative luminance of an opaque colour.
178pub fn relative_luminance(c: Rgba) -> f64 {
179    let lin = |v: f64| {
180        let s = v / 255.0;
181        if s <= 0.03928 {
182            s / 12.92
183        } else {
184            ((s + 0.055) / 1.055).powf(2.4)
185        }
186    };
187    0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b)
188}
189
190/// WCAG contrast ratio (1.0 to 21.0) of `fg` on `bg`. A translucent `fg` is composited over `bg`
191/// first; a translucent `bg` over white (the page canvas).
192pub fn contrast_ratio(fg: Rgba, bg: Rgba) -> f64 {
193    let bg = bg.over(Rgba::WHITE);
194    let fg = fg.over(bg);
195    let (a, b) = (relative_luminance(fg), relative_luminance(bg));
196    let (hi, lo) = if a >= b { (a, b) } else { (b, a) };
197    (hi + 0.05) / (lo + 0.05)
198}
199
200/// The colour actually behind an element: `chain` holds the computed `background-color` of the
201/// element then each ancestor up to the root (innermost first). They are painted over a white canvas.
202pub fn effective_background(chain: &[Rgba]) -> Rgba {
203    chain
204        .iter()
205        .rev()
206        .fold(Rgba::WHITE, |under, layer| layer.over(under))
207}
208
209/// WCAG "large text": at least 24 CSS px, or at least 18.66 px (14 pt) and bold (weight 700+).
210pub fn is_large_text(font_px: f64, font_weight: f64) -> bool {
211    font_px >= 24.0 || (font_px >= 18.66 && font_weight >= 700.0)
212}
213
214/// The minimum ratio for a conformance `level` (`AA` or `AAA`, any case) and text size.
215pub fn contrast_threshold(level: &str, large: bool) -> Result<f64> {
216    match (level.trim().to_ascii_uppercase().as_str(), large) {
217        ("AA", false) => Ok(4.5),
218        ("AA", true) => Ok(3.0),
219        ("AAA", false) => Ok(7.0),
220        ("AAA", true) => Ok(4.5),
221        _ => err(format!("contrast level must be AA or AAA, got '{level}'")),
222    }
223}
224
225/// Page-side read for [`contrast_report`]: the element's text colour, font size and weight, and the
226/// computed `background-color` of it and every ancestor. `selector` is the first match.
227pub fn contrast_probe_js(selector: &str) -> String {
228    let sel = serde_json::to_string(selector).unwrap_or_else(|_| "\"\"".into());
229    format!(
230        "const el=document.querySelector({sel});if(!el)return {{found:false}};\
231         const cs=getComputedStyle(el);const chain=[];let hasImage=false;\
232         for(let n=el;n;n=n.parentElement){{const s=getComputedStyle(n);chain.push(s.backgroundColor);\
233         if(s.backgroundImage&&s.backgroundImage!=='none')hasImage=true;}}\
234         return {{found:true,color:cs.color,fontSize:parseFloat(cs.fontSize),fontWeight:parseFloat(cs.fontWeight)||400,\
235         opacity:parseFloat(cs.opacity),chain,hasImage}};"
236    )
237}
238
239/// How a contrast assertion is read: an explicit `min` wins; otherwise `level` (default `AA`) with
240/// `large` (default: decided from the element's font size and weight).
241#[derive(Debug, Clone, Default)]
242pub struct ContrastOptions {
243    pub level: Option<String>,
244    pub min: Option<f64>,
245    pub large: Option<bool>,
246    /// Report the ratio without failing when it is below the minimum.
247    pub report_only: bool,
248}
249
250/// Turn the page's reply to [`contrast_probe_js`] into a verdict. `Err` when the element is missing,
251/// a colour cannot be read, or the ratio is below the minimum (unless `report_only`).
252pub fn contrast_report(selector: &str, probe: &Value, opts: &ContrastOptions) -> Result<Value> {
253    if probe.get("found").and_then(Value::as_bool) != Some(true) {
254        return err(format!("contrast: no element matches '{selector}'"));
255    }
256    let text = |k: &str| probe.get(k).and_then(Value::as_str).unwrap_or_default();
257    let fg = parse_css_color(text("color")).ok_or_else(|| {
258        Error(format!(
259            "contrast: cannot read text colour '{}' of '{selector}'",
260            text("color")
261        ))
262    })?;
263    let mut chain = Vec::new();
264    for c in probe
265        .get("chain")
266        .and_then(Value::as_array)
267        .into_iter()
268        .flatten()
269    {
270        let s = c.as_str().unwrap_or_default();
271        chain.push(parse_css_color(s).ok_or_else(|| {
272            Error(format!(
273                "contrast: cannot read background colour '{s}' behind '{selector}'"
274            ))
275        })?);
276    }
277    let bg = effective_background(&chain);
278    // The element's own opacity dims its text against the background.
279    let opacity = probe
280        .get("opacity")
281        .and_then(Value::as_f64)
282        .unwrap_or(1.0)
283        .clamp(0.0, 1.0);
284    let fg = Rgba {
285        a: fg.a * opacity,
286        ..fg
287    };
288    let ratio = contrast_ratio(fg, bg);
289    let px = probe
290        .get("fontSize")
291        .and_then(Value::as_f64)
292        .unwrap_or(16.0);
293    let weight = probe
294        .get("fontWeight")
295        .and_then(Value::as_f64)
296        .unwrap_or(400.0);
297    let large = opts.large.unwrap_or_else(|| is_large_text(px, weight));
298    let level = opts.level.clone().unwrap_or_else(|| "AA".into());
299    let min = match opts.min {
300        Some(m) => m,
301        None => contrast_threshold(&level, large)?,
302    };
303    let has_image = probe
304        .get("hasImage")
305        .and_then(Value::as_bool)
306        .unwrap_or(false);
307    let passes = ratio >= min;
308    if !passes && !opts.report_only {
309        return err(format!(
310            "contrast of '{selector}' is {ratio:.2}:1, below the {min}:1 minimum (text {}, background {}{})",
311            fg.over(bg).hex(),
312            bg.hex(),
313            if has_image { "; a background image is also present, so the measured background is approximate" } else { "" }
314        ));
315    }
316    Ok(json!({
317        "ratio": (ratio * 100.0).round() / 100.0, "min": min, "passes": passes, "large": large,
318        "fg": fg.over(bg).hex(), "bg": bg.hex(), "font_px": px, "font_weight": weight,
319        "background_image": has_image,
320    }))
321}
322
323// -------------------------------------------------------------------- focus
324
325/// Page-side read of the focused element's indicator: its computed outline and box-shadow, whether
326/// `:focus-visible` matches, and its identity. With `selector`, `matches` says whether the focused
327/// element is the one the selector names.
328pub fn focus_probe_js(selector: Option<&str>) -> String {
329    let sel = selector
330        .map(|s| serde_json::to_string(s).unwrap_or_else(|_| "null".into()))
331        .unwrap_or_else(|| "null".into());
332    format!(
333        "const sel={sel};const el=document.activeElement;\
334         if(!el||el===document.body||el===document.documentElement)return {{focused:false,matches:null}};\
335         const cs=getComputedStyle(el);const r=el.getBoundingClientRect();let fv=null;\
336         try{{fv=el.matches(':focus-visible');}}catch(e){{}}\
337         return {{focused:true,matches:sel?el.matches(sel):null,tag:el.tagName.toLowerCase(),id:el.id||null,\
338         role:el.getAttribute('role'),name:(el.getAttribute('aria-label')||el.innerText||el.value||'').trim().slice(0,80),\
339         focusVisible:fv,outlineStyle:cs.outlineStyle,outlineWidth:cs.outlineWidth,outlineColor:cs.outlineColor,\
340         outlineOffset:cs.outlineOffset,boxShadow:cs.boxShadow,rect:[r.x,r.y,r.width,r.height]}};"
341    )
342}
343
344fn px_value(token: &str) -> Option<f64> {
345    token
346        .trim()
347        .strip_suffix("px")
348        .and_then(|v| v.trim().parse::<f64>().ok())
349}
350
351/// Split on whitespace outside parentheses (a computed `box-shadow` colour holds spaces).
352fn top_level_tokens(text: &str) -> Vec<String> {
353    let (mut out, mut cur, mut depth) = (Vec::new(), String::new(), 0i32);
354    for c in text.chars() {
355        match c {
356            '(' => {
357                depth += 1;
358                cur.push(c);
359            }
360            ')' => {
361                depth -= 1;
362                cur.push(c);
363            }
364            c if c.is_whitespace() && depth == 0 => {
365                if !cur.is_empty() {
366                    out.push(std::mem::take(&mut cur));
367                }
368            }
369            c => cur.push(c),
370        }
371    }
372    if !cur.is_empty() {
373        out.push(cur);
374    }
375    out
376}
377
378/// Split on commas outside parentheses (one entry per shadow).
379fn top_level_commas(text: &str) -> Vec<String> {
380    let (mut out, mut cur, mut depth) = (Vec::new(), String::new(), 0i32);
381    for c in text.chars() {
382        match c {
383            '(' => {
384                depth += 1;
385                cur.push(c);
386            }
387            ')' => {
388                depth -= 1;
389                cur.push(c);
390            }
391            ',' if depth == 0 => out.push(std::mem::take(&mut cur)),
392            c => cur.push(c),
393        }
394    }
395    if !cur.trim().is_empty() {
396        out.push(cur);
397    }
398    out
399}
400
401/// True when a computed `box-shadow` paints something: a shadow with a non-transparent colour and a
402/// non-zero offset, blur or spread. `none` and fully transparent shadows are not an indicator.
403pub fn box_shadow_visible(box_shadow: &str) -> bool {
404    let text = box_shadow.trim();
405    if text.is_empty() || text == "none" {
406        return false;
407    }
408    top_level_commas(text).iter().any(|one| {
409        let tokens = top_level_tokens(one);
410        let colour = tokens.iter().find_map(|t| parse_css_color(t));
411        let lengths: Vec<f64> = tokens.iter().filter_map(|t| px_value(t)).collect();
412        // A shadow with no parsable colour uses currentColor, which is visible.
413        colour.is_none_or(|c| c.a > 0.0) && lengths.iter().any(|v| *v != 0.0)
414    })
415}
416
417/// Verdict over the reply to [`focus_probe_js`]: does the focused element show a visible indicator,
418/// and which kind (`outline`, `box-shadow`, or `none`). A focused element with neither is a failed
419/// focus-visibility check (`visible = false`), not an error; the step's `expect` decides.
420pub fn focus_ring_verdict(probe: &Value) -> Value {
421    if probe.get("focused").and_then(Value::as_bool) != Some(true) {
422        return json!({"focused": false, "visible": false, "kind": "none", "reason": "no element has keyboard focus"});
423    }
424    let text = |k: &str| probe.get(k).and_then(Value::as_str).unwrap_or_default();
425    let style = text("outlineStyle");
426    let width = px_value(text("outlineWidth")).unwrap_or(0.0);
427    let colour_visible = parse_css_color(text("outlineColor")).is_none_or(|c| c.a > 0.0);
428    let outline = !matches!(style, "" | "none" | "hidden") && width > 0.0 && colour_visible;
429    let shadow = box_shadow_visible(text("boxShadow"));
430    let (kind, reason) = if outline {
431        (
432            "outline",
433            format!("outline {style} {width}px {}", text("outlineColor")),
434        )
435    } else if shadow {
436        ("box-shadow", format!("box-shadow {}", text("boxShadow")))
437    } else {
438        (
439            "none",
440            format!(
441                "outline is '{style} {}' and box-shadow is '{}'",
442                text("outlineWidth"),
443                text("boxShadow")
444            ),
445        )
446    };
447    json!({
448        "focused": true, "visible": kind != "none", "kind": kind, "reason": reason,
449        "matches": probe.get("matches").cloned().unwrap_or(Value::Null),
450        "focus_visible": probe.get("focusVisible").cloned().unwrap_or(Value::Null),
451        "tag": probe.get("tag").cloned().unwrap_or(Value::Null),
452        "id": probe.get("id").cloned().unwrap_or(Value::Null),
453        "role": probe.get("role").cloned().unwrap_or(Value::Null),
454        "name": probe.get("name").cloned().unwrap_or(Value::Null),
455        "rect": probe.get("rect").cloned().unwrap_or(Value::Null),
456        "outline": {"style": style, "width": text("outlineWidth"), "color": text("outlineColor"), "offset": text("outlineOffset")},
457        "box_shadow": text("boxShadow"),
458    })
459}
460
461// ----------------------------------------------------------------------- ax
462
463/// A role/name assertion over the control server's `ax` nodes (`{role, name, disabled, ...}`).
464#[derive(Debug, Clone, Default)]
465pub struct AxQuery {
466    /// ARIA or implicit role, compared case-insensitively.
467    pub role: Option<String>,
468    /// Accessible name, equal after trimming and collapsing whitespace.
469    pub name: Option<String>,
470    /// Accessible name contains this text (case-insensitive).
471    pub name_contains: Option<String>,
472    /// The accessible name must not be empty.
473    pub named: bool,
474    pub disabled: Option<bool>,
475    /// At least this many nodes must match (default 1).
476    pub min_count: Option<usize>,
477    pub max_count: Option<usize>,
478}
479
480impl AxQuery {
481    pub fn from_params(p: &Map<String, Value>) -> Result<Self> {
482        let s = |k: &str| p.get(k).and_then(Value::as_str).map(String::from);
483        let n = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| v as usize);
484        let q = AxQuery {
485            role: s("role"),
486            name: s("name"),
487            name_contains: s("name_contains"),
488            named: p.get("named").and_then(Value::as_bool).unwrap_or(false),
489            disabled: p.get("disabled").and_then(Value::as_bool),
490            min_count: n("min_count"),
491            max_count: n("max_count"),
492        };
493        if q.role.is_none() && q.name.is_none() && q.name_contains.is_none() && !q.named {
494            return err("ui_assert_ax needs at least one of role, name, name_contains or named");
495        }
496        Ok(q)
497    }
498
499    fn matches(&self, node: &Value) -> bool {
500        let role = node.get("role").and_then(Value::as_str).unwrap_or_default();
501        let name = collapse(node.get("name").and_then(Value::as_str).unwrap_or_default());
502        self.role
503            .as_deref()
504            .is_none_or(|r| role.eq_ignore_ascii_case(r.trim()))
505            && self.name.as_deref().is_none_or(|n| name == collapse(n))
506            && self
507                .name_contains
508                .as_deref()
509                .is_none_or(|n| name.to_lowercase().contains(&collapse(n).to_lowercase()))
510            && (!self.named || !name.is_empty())
511            && self.disabled.is_none_or(|d| {
512                node.get("disabled")
513                    .and_then(Value::as_bool)
514                    .unwrap_or(false)
515                    == d
516            })
517    }
518}
519
520fn collapse(s: &str) -> String {
521    s.split_whitespace().collect::<Vec<_>>().join(" ")
522}
523
524/// The nodes that satisfy `q`.
525pub fn ax_find<'a>(nodes: &'a [Value], q: &AxQuery) -> Vec<&'a Value> {
526    nodes.iter().filter(|n| q.matches(n)).collect()
527}
528
529/// Assert `q` over `nodes`. Returns `{count, matches}`; the error lists the roles and names that
530/// exist so a mismatch can be read without a second run.
531pub fn ax_assert(nodes: &[Value], q: &AxQuery) -> Result<Value> {
532    let found = ax_find(nodes, q);
533    let (min, max) = (q.min_count.unwrap_or(1), q.max_count);
534    if found.len() < min || max.is_some_and(|m| found.len() > m) {
535        let seen: Vec<String> = nodes
536            .iter()
537            .take(40)
538            .map(|n| {
539                format!(
540                    "{}:'{}'",
541                    n.get("role").and_then(Value::as_str).unwrap_or("?"),
542                    collapse(n.get("name").and_then(Value::as_str).unwrap_or_default())
543                )
544            })
545            .collect();
546        return err(format!(
547            "ax: expected {min}{} node(s) matching {q:?}, found {} among {} node(s): [{}]",
548            max.map(|m| format!("..{m}")).unwrap_or_default(),
549            found.len(),
550            nodes.len(),
551            seen.join(", ")
552        ));
553    }
554    Ok(json!({"count": found.len(), "matches": found}))
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    fn rgb(r: u8, g: u8, b: u8) -> Rgba {
562        Rgba {
563            r: f64::from(r),
564            g: f64::from(g),
565            b: f64::from(b),
566            a: 1.0,
567        }
568    }
569
570    #[test]
571    fn parses_the_colour_forms_a_browser_reports() {
572        assert_eq!(parse_css_color("rgb(12, 34, 56)"), Some(rgb(12, 34, 56)));
573        assert_eq!(
574            parse_css_color("rgba(0, 0, 0, 0)"),
575            Some(Rgba {
576                a: 0.0,
577                ..rgb(0, 0, 0)
578            })
579        );
580        assert_eq!(
581            parse_css_color("rgb(10 20 30 / 50%)"),
582            Some(Rgba {
583                a: 0.5,
584                ..rgb(10, 20, 30)
585            })
586        );
587        assert_eq!(parse_css_color("#fff"), Some(Rgba::WHITE));
588        assert_eq!(parse_css_color("#102030"), Some(rgb(16, 32, 48)));
589        assert_eq!(
590            parse_css_color("#10203080").map(|c| (c.a * 255.0).round()),
591            Some(128.0)
592        );
593        assert_eq!(parse_css_color("transparent"), Some(Rgba::TRANSPARENT));
594        let c = parse_css_color("color(srgb 1 0.5 0 / 0.25)").unwrap();
595        assert_eq!((c.r, c.g.round(), c.b, c.a), (255.0, 128.0, 0.0, 0.25));
596        for bad in ["", "red", "oklch(0.5 0.1 20)", "rgb(1, 2)", "#12", "#ggg"] {
597            assert_eq!(parse_css_color(bad), None, "{bad}");
598        }
599    }
600
601    #[test]
602    fn contrast_ratio_matches_the_wcag_reference_values() {
603        let black = rgb(0, 0, 0);
604        assert!((contrast_ratio(black, Rgba::WHITE) - 21.0).abs() < 1e-9);
605        assert!((contrast_ratio(Rgba::WHITE, Rgba::WHITE) - 1.0).abs() < 1e-9);
606        // Order does not matter: the ratio is light over dark.
607        assert_eq!(
608            contrast_ratio(black, Rgba::WHITE),
609            contrast_ratio(Rgba::WHITE, black)
610        );
611        // #767676 is the lightest grey that passes AA on white; #777777 just fails.
612        assert!(contrast_ratio(rgb(0x76, 0x76, 0x76), Rgba::WHITE) >= 4.5);
613        assert!(contrast_ratio(rgb(0x77, 0x77, 0x77), Rgba::WHITE) < 4.5);
614    }
615
616    #[test]
617    fn translucent_layers_composite_before_the_ratio() {
618        // Half-transparent black text on white reads as mid grey: 21:1 down to about 4:1.
619        let half = Rgba {
620            a: 0.5,
621            ..rgb(0, 0, 0)
622        };
623        let r = contrast_ratio(half, Rgba::WHITE);
624        assert!(r > 3.8 && r < 4.2, "{r}");
625        // Background chain, innermost first: transparent, then 50% black, then the page (transparent).
626        let chain = [
627            Rgba::TRANSPARENT,
628            Rgba {
629                a: 0.5,
630                ..rgb(0, 0, 0)
631            },
632            Rgba::TRANSPARENT,
633        ];
634        let bg = effective_background(&chain);
635        assert_eq!(bg.hex(), "#808080");
636        assert_eq!(effective_background(&[]).hex(), "#ffffff");
637        // The inner opaque layer wins.
638        assert_eq!(
639            effective_background(&[rgb(1, 2, 3), rgb(9, 9, 9)]).hex(),
640            "#010203"
641        );
642    }
643
644    #[test]
645    fn large_text_and_thresholds() {
646        assert!(is_large_text(24.0, 400.0));
647        assert!(is_large_text(18.66, 700.0));
648        assert!(!is_large_text(18.66, 400.0));
649        assert!(!is_large_text(16.0, 700.0));
650        assert_eq!(contrast_threshold("aa", false).unwrap(), 4.5);
651        assert_eq!(contrast_threshold("AA", true).unwrap(), 3.0);
652        assert_eq!(contrast_threshold("AAA", false).unwrap(), 7.0);
653        assert_eq!(contrast_threshold("AAA", true).unwrap(), 4.5);
654        assert!(contrast_threshold("A", false).is_err());
655    }
656
657    fn probe(color: &str, chain: &[&str], px: f64) -> Value {
658        json!({"found": true, "color": color, "chain": chain, "fontSize": px, "fontWeight": 400, "opacity": 1, "hasImage": false})
659    }
660
661    #[test]
662    fn contrast_report_passes_fails_and_reports() {
663        let ok = contrast_report(
664            "#a",
665            &probe(
666                "rgb(0, 0, 0)",
667                &["rgba(0, 0, 0, 0)", "rgb(255, 255, 255)"],
668                16.0,
669            ),
670            &ContrastOptions::default(),
671        )
672        .unwrap();
673        assert_eq!(ok["ratio"], json!(21.0));
674        assert_eq!(ok["passes"], json!(true));
675        let low = probe("rgb(119, 119, 119)", &["rgb(255, 255, 255)"], 16.0);
676        let e = contrast_report("#a", &low, &ContrastOptions::default()).unwrap_err();
677        assert!(e.0.contains("#a") && e.0.contains("4.5:1"), "{e}");
678        // The same colours pass as large text, with an explicit lower minimum, or when only reporting.
679        assert!(contrast_report(
680            "#a",
681            &low,
682            &ContrastOptions {
683                large: Some(true),
684                ..Default::default()
685            }
686        )
687        .is_ok());
688        assert!(contrast_report(
689            "#a",
690            &low,
691            &ContrastOptions {
692                min: Some(4.0),
693                ..Default::default()
694            }
695        )
696        .is_ok());
697        let rep = contrast_report(
698            "#a",
699            &low,
700            &ContrastOptions {
701                report_only: true,
702                ..Default::default()
703            },
704        )
705        .unwrap();
706        assert_eq!(rep["passes"], json!(false));
707        // A large font size picks the large threshold on its own.
708        assert_eq!(
709            contrast_report(
710                "#a",
711                &probe("rgb(119, 119, 119)", &["rgb(255, 255, 255)"], 28.0),
712                &ContrastOptions::default()
713            )
714            .unwrap()["large"],
715            json!(true)
716        );
717        // Missing element and unreadable colours are errors, not silent passes.
718        assert!(
719            contrast_report("#a", &json!({"found": false}), &ContrastOptions::default()).is_err()
720        );
721        assert!(contrast_report(
722            "#a",
723            &probe("oklch(0.5 0.1 20)", &[], 16.0),
724            &ContrastOptions::default()
725        )
726        .is_err());
727        assert!(contrast_report(
728            "#a",
729            &probe("rgb(0,0,0)", &["lab(1 2 3)"], 16.0),
730            &ContrastOptions::default()
731        )
732        .is_err());
733    }
734
735    #[test]
736    fn probes_quote_the_selector() {
737        let js = contrast_probe_js("a[href=\"x\"]");
738        assert!(js.contains(r#"querySelector("a[href=\"x\"]")"#), "{js}");
739        assert!(focus_probe_js(None).contains("const sel=null;"));
740        assert!(focus_probe_js(Some("#go")).contains(r##"const sel="#go";"##));
741    }
742
743    #[test]
744    fn box_shadow_is_an_indicator_only_when_it_paints() {
745        assert!(box_shadow_visible("rgb(0, 95, 204) 0px 0px 0px 2px"));
746        assert!(box_shadow_visible(
747            "rgba(0, 0, 0, 0.3) 0px 1px 4px 0px, rgb(0, 95, 204) 0px 0px 0px 3px"
748        ));
749        assert!(!box_shadow_visible("none"));
750        assert!(!box_shadow_visible(""));
751        assert!(!box_shadow_visible("rgba(0, 0, 0, 0) 0px 0px 0px 2px"));
752        assert!(!box_shadow_visible("rgb(0, 95, 204) 0px 0px 0px 0px"));
753    }
754
755    #[test]
756    fn focus_verdict_reads_outline_shadow_or_neither() {
757        let base = json!({"focused": true, "matches": true, "tag": "button", "id": "go", "focusVisible": true,
758            "outlineStyle": "auto", "outlineWidth": "1px", "outlineColor": "rgb(16, 16, 16)", "outlineOffset": "0px", "boxShadow": "none"});
759        let v = focus_ring_verdict(&base);
760        assert_eq!(
761            (v["visible"].clone(), v["kind"].clone()),
762            (json!(true), json!("outline"))
763        );
764        assert_eq!(v["id"], json!("go"));
765        let mut shadow = base.clone();
766        shadow["outlineStyle"] = json!("none");
767        shadow["outlineWidth"] = json!("0px");
768        shadow["boxShadow"] = json!("rgb(0, 95, 204) 0px 0px 0px 2px");
769        assert_eq!(focus_ring_verdict(&shadow)["kind"], json!("box-shadow"));
770        let mut none = shadow.clone();
771        none["boxShadow"] = json!("none");
772        let v = focus_ring_verdict(&none);
773        assert_eq!(
774            (v["visible"].clone(), v["kind"].clone()),
775            (json!(false), json!("none"))
776        );
777        // A zero-width or transparent outline is no outline.
778        let mut zero = base.clone();
779        zero["outlineWidth"] = json!("0px");
780        assert_eq!(focus_ring_verdict(&zero)["visible"], json!(false));
781        let mut clear = base;
782        clear["outlineColor"] = json!("rgba(0, 0, 0, 0)");
783        assert_eq!(focus_ring_verdict(&clear)["visible"], json!(false));
784        let unfocused = focus_ring_verdict(&json!({"focused": false}));
785        assert_eq!(
786            (unfocused["focused"].clone(), unfocused["visible"].clone()),
787            (json!(false), json!(false))
788        );
789    }
790
791    fn nodes() -> Vec<Value> {
792        vec![
793            json!({"role": "button", "name": "Save  project", "disabled": false}),
794            json!({"role": "button", "name": "", "disabled": true}),
795            json!({"role": "textbox", "name": "Title", "disabled": false}),
796            json!({"role": "heading", "name": "Settings"}),
797        ]
798    }
799
800    fn query(v: Value) -> AxQuery {
801        AxQuery::from_params(v.as_object().unwrap()).unwrap()
802    }
803
804    #[test]
805    fn ax_role_and_name_matching() {
806        let n = nodes();
807        assert_eq!(ax_find(&n, &query(json!({"role": "BUTTON"}))).len(), 2);
808        // Names compare after whitespace is collapsed.
809        assert_eq!(
810            ax_find(
811                &n,
812                &query(json!({"role": "button", "name": "Save project"}))
813            )
814            .len(),
815            1
816        );
817        assert_eq!(
818            ax_find(&n, &query(json!({"name_contains": "save"}))).len(),
819            1
820        );
821        assert_eq!(
822            ax_find(&n, &query(json!({"role": "button", "named": true}))).len(),
823            1
824        );
825        assert_eq!(
826            ax_find(&n, &query(json!({"role": "button", "disabled": true}))).len(),
827            1
828        );
829        assert_eq!(ax_find(&n, &query(json!({"name": "save"}))).len(), 0);
830    }
831
832    #[test]
833    fn ax_assert_counts_and_explains_a_miss() {
834        let n = nodes();
835        assert_eq!(
836            ax_assert(&n, &query(json!({"role": "textbox", "name": "Title"}))).unwrap()["count"],
837            json!(1)
838        );
839        let e = ax_assert(&n, &query(json!({"role": "button", "name": "Delete"}))).unwrap_err();
840        assert!(
841            e.0.contains("Delete") && e.0.contains("textbox:'Title'"),
842            "{e}"
843        );
844        assert!(ax_assert(&n, &query(json!({"role": "button", "max_count": 1}))).is_err());
845        assert!(ax_assert(&n, &query(json!({"role": "button", "min_count": 2}))).is_ok());
846        assert!(ax_assert(
847            &n,
848            &query(json!({"role": "link", "min_count": 0, "max_count": 0}))
849        )
850        .is_ok());
851        assert!(AxQuery::from_params(json!({"min_count": 1}).as_object().unwrap()).is_err());
852    }
853}