1use crate::util::{err, Error, Result};
9use serde_json::{json, Map, Value};
10
11#[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 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
85pub 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
177pub 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
190pub 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
200pub fn effective_background(chain: &[Rgba]) -> Rgba {
203 chain
204 .iter()
205 .rev()
206 .fold(Rgba::WHITE, |under, layer| layer.over(under))
207}
208
209pub 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
214pub 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
225pub 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#[derive(Debug, Clone, Default)]
242pub struct ContrastOptions {
243 pub level: Option<String>,
244 pub min: Option<f64>,
245 pub large: Option<bool>,
246 pub report_only: bool,
248}
249
250pub 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 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
323pub 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
351fn 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
378fn 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
401pub 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 colour.is_none_or(|c| c.a > 0.0) && lengths.iter().any(|v| *v != 0.0)
414 })
415}
416
417pub 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#[derive(Debug, Clone, Default)]
465pub struct AxQuery {
466 pub role: Option<String>,
468 pub name: Option<String>,
470 pub name_contains: Option<String>,
472 pub named: bool,
474 pub disabled: Option<bool>,
475 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
524pub fn ax_find<'a>(nodes: &'a [Value], q: &AxQuery) -> Vec<&'a Value> {
526 nodes.iter().filter(|n| q.matches(n)).collect()
527}
528
529pub 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 assert_eq!(
608 contrast_ratio(black, Rgba::WHITE),
609 contrast_ratio(Rgba::WHITE, black)
610 );
611 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 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 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 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 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 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 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 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 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}