1use crate::{
51 accessibility::utils::{
52 get_missing_required_aria_properties, is_valid_aria_role,
53 is_valid_language_code,
54 },
55 emojis::load_emoji_sequences,
56};
57use once_cell::sync::Lazy;
58use regex::Regex;
59use scraper::{CaseSensitivity, ElementRef, Html, Selector};
60use std::collections::{HashMap, HashSet};
61use thiserror::Error;
62
63pub mod constants {
76 pub const MAX_HTML_SIZE: usize = 1_000_000;
85
86 pub const DEFAULT_NAV_ROLE: &str = "navigation";
95
96 pub const DEFAULT_BUTTON_ROLE: &str = "button";
105
106 pub const DEFAULT_FORM_ROLE: &str = "form";
115
116 pub const DEFAULT_INPUT_ROLE: &str = "textbox";
125}
126
127use constants::{DEFAULT_BUTTON_ROLE, DEFAULT_NAV_ROLE, MAX_HTML_SIZE};
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
141pub enum WcagLevel {
142 A,
145
146 AA,
149
150 AAA,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum IssueType {
167 MissingAltText,
169 HeadingStructure,
171 MissingLabels,
173 InvalidAria,
175 ColorContrast,
177 KeyboardNavigation,
179 LanguageDeclaration,
181}
182
183#[derive(Debug, Error)]
197pub enum Error {
198 #[error("Invalid ARIA Attribute '{attribute}': {message}")]
200 InvalidAriaAttribute {
201 attribute: String,
203 message: String,
205 },
206
207 #[error("WCAG {level} Validation Error: {message}")]
209 WcagValidationError {
210 level: WcagLevel,
212 message: String,
214 guideline: Option<String>,
216 },
217
218 #[error(
220 "HTML Input Too Large: size {size} exceeds maximum {max_size}"
221 )]
222 HtmlTooLarge {
223 size: usize,
225 max_size: usize,
227 },
228
229 #[error("HTML Processing Error: {message}")]
231 HtmlProcessingError {
232 message: String,
234 source: Option<Box<dyn std::error::Error + Send + Sync>>,
236 },
237
238 #[error("Malformed HTML: {message}")]
240 MalformedHtml {
241 message: String,
243 fragment: Option<String>,
245 },
246}
247
248pub type Result<T> = std::result::Result<T, Error>;
261
262#[derive(Debug, Clone)]
279pub struct Issue {
280 pub issue_type: IssueType,
282 pub message: String,
284 pub guideline: Option<String>,
286 pub element: Option<String>,
288 pub suggestion: Option<String>,
290}
291
292fn try_create_selector(selector: &str) -> Selector {
298 Selector::parse(selector).unwrap_or_else(|err| {
299 panic!("static selector {selector:?} must parse: {err}")
300 })
301}
302
303fn try_create_regex(pattern: &str) -> Regex {
308 Regex::new(pattern).unwrap_or_else(|err| {
309 panic!("static regex {pattern:?} must compile: {err}")
310 })
311}
312
313static BUTTON_SELECTOR: Lazy<Selector> =
315 Lazy::new(|| try_create_selector("button:not([aria-label])"));
316
317static NAV_SELECTOR: Lazy<Selector> =
319 Lazy::new(|| try_create_selector("nav:not([aria-label])"));
320
321static FORM_SELECTOR: Lazy<Selector> =
323 Lazy::new(|| try_create_selector("form:not([aria-labelledby])"));
324
325static INPUT_REGEX: Lazy<Regex> =
327 Lazy::new(|| try_create_regex(r"<input[^>]*>"));
328
329static ARIA_SELECTOR: Lazy<Selector> = Lazy::new(|| {
331 try_create_selector(concat!(
332 "[aria-label], [aria-labelledby], [aria-describedby], ",
333 "[aria-hidden], [aria-expanded], [aria-haspopup], ",
334 "[aria-controls], [aria-pressed], [aria-checked], ",
335 "[aria-current], [aria-disabled], [aria-dropeffect], ",
336 "[aria-grabbed], [aria-invalid], [aria-live], ",
337 "[aria-owns], [aria-relevant], [aria-required], ",
338 "[aria-role], [aria-selected], [aria-valuemax], ",
339 "[aria-valuemin], [aria-valuenow], [aria-valuetext]"
340 ))
341});
342
343static ALL_BUTTON_SELECTOR: Lazy<Selector> =
345 Lazy::new(|| try_create_selector("button"));
346
347static DIALOG_DESC_SELECTOR: Lazy<Selector> =
349 Lazy::new(|| try_create_selector("p, .dialog-description"));
350
351static HTML_SELECTOR: Lazy<Selector> =
353 Lazy::new(|| try_create_selector("html"));
354
355static LANG_SELECTOR: Lazy<Selector> =
357 Lazy::new(|| try_create_selector("[lang]"));
358
359static ROLE_SELECTOR: Lazy<Selector> =
361 Lazy::new(|| try_create_selector("[role]"));
362
363static INTERACTIVE_SELECTOR: Lazy<Selector> = Lazy::new(|| {
365 try_create_selector(
366 "a, button, input, select, textarea, [tabindex]",
367 )
368});
369
370static INPUT_ID_REGEX: Lazy<Regex> =
372 Lazy::new(|| try_create_regex(r#"id="([^"]+)""#));
373
374static VALID_ARIA_ATTRIBUTES: Lazy<HashSet<&'static str>> =
376 Lazy::new(|| {
377 [
378 "aria-label",
379 "aria-labelledby",
380 "aria-describedby",
381 "aria-hidden",
382 "aria-expanded",
383 "aria-haspopup",
384 "aria-controls",
385 "aria-pressed",
386 "aria-checked",
387 "aria-current",
388 "aria-disabled",
389 "aria-dropeffect",
390 "aria-grabbed",
391 "aria-invalid",
392 "aria-live",
393 "aria-owns",
394 "aria-relevant",
395 "aria-required",
396 "aria-role",
397 "aria-selected",
398 "aria-valuemax",
399 "aria-valuemin",
400 "aria-valuenow",
401 "aria-valuetext",
402 ]
403 .iter()
404 .copied()
405 .collect()
406 });
407
408static SHORTHAND_ATTR_REGEX: Lazy<Regex> = Lazy::new(|| {
410 Regex::new(r"\b(disabled|checked|readonly|multiple|selected|autofocus|required)([\s>])")
411 .expect("Failed to compile shorthand attribute regex")
412});
413
414static UNIVERSAL_SELECTOR: Lazy<Selector> = Lazy::new(|| {
416 Selector::parse("*").expect("Failed to compile universal selector")
417});
418
419static ID_ATTR_REGEX: Lazy<Regex> = Lazy::new(|| {
421 Regex::new(r#"id="([^"]+)""#)
422 .expect("Failed to compile id attribute regex")
423});
424
425#[derive(Debug, Copy, Clone)]
466pub struct AccessibilityConfig {
467 pub wcag_level: WcagLevel,
469 pub max_heading_jump: u8,
471 pub min_contrast_ratio: f64,
473 pub auto_fix: bool,
475}
476
477impl Default for AccessibilityConfig {
478 fn default() -> Self {
479 Self {
480 wcag_level: WcagLevel::AA,
481 max_heading_jump: 1,
482 min_contrast_ratio: 4.5, auto_fix: true,
484 }
485 }
486}
487
488#[derive(Debug, Clone)]
502pub struct AccessibilityReport {
503 pub issues: Vec<Issue>,
505 pub wcag_level: WcagLevel,
507 pub elements_checked: usize,
509 pub issue_count: usize,
511 pub check_duration_ms: u64,
513}
514
515pub fn add_aria_attributes(
551 html: &str,
552 config: Option<AccessibilityConfig>,
553) -> Result<String> {
554 let _config = config.unwrap_or_default();
555
556 if html.len() > MAX_HTML_SIZE {
557 return Err(Error::HtmlTooLarge {
558 size: html.len(),
559 max_size: MAX_HTML_SIZE,
560 });
561 }
562
563 let input_had_aria = html.contains("aria-");
572
573 let mut html_builder = HtmlBuilder::new(html);
574
575 html_builder = add_aria_to_accordions(html_builder)?;
577 html_builder = add_aria_to_modals(html_builder)?;
578 html_builder = add_aria_to_buttons(html_builder)?;
579 html_builder = add_aria_to_forms(html_builder)?;
580 html_builder = add_aria_to_inputs(html_builder)?;
581 html_builder = add_aria_to_navs(html_builder)?;
582 html_builder = add_aria_to_tabs(html_builder)?;
583 html_builder = add_aria_to_toggle(html_builder)?;
584 html_builder = add_aria_to_tooltips(html_builder)?;
585
586 let final_html = html_builder.build();
587 if !input_had_aria {
588 return Ok(final_html);
592 }
593
594 Ok(remove_invalid_aria_attributes(&final_html))
599}
600
601#[derive(Debug, Clone)]
603struct HtmlBuilder {
604 content: String,
605}
606
607impl HtmlBuilder {
608 fn new(initial_content: &str) -> Self {
610 HtmlBuilder {
611 content: initial_content.to_string(),
612 }
613 }
614
615 fn build(self) -> String {
617 self.content
618 }
619}
620
621fn count_checked_elements(document: &Html) -> usize {
623 document.select(&UNIVERSAL_SELECTOR).count()
624}
625
626fn check_heading_structure(document: &Html, issues: &mut Vec<Issue>) {
628 let mut prev_level: Option<u8> = None;
629
630 let selector = match Selector::parse("h1, h2, h3, h4, h5, h6") {
631 Ok(selector) => selector,
632 Err(_) => {
633 return; }
635 };
636
637 for heading in document.select(&selector) {
638 let current_level = heading
639 .value()
640 .name()
641 .chars()
642 .nth(1)
643 .and_then(|c| c.to_digit(10))
644 .and_then(|n| u8::try_from(n).ok());
645
646 if let Some(current_level) = current_level {
647 if let Some(prev_level) = prev_level {
648 if current_level > prev_level + 1 {
649 issues.push(Issue {
650 issue_type: IssueType::HeadingStructure,
651 message: format!(
652 "Skipped heading level from h{} to h{}",
653 prev_level, current_level
654 ),
655 guideline: Some("WCAG 2.4.6".to_string()),
656 element: Some(heading.html()),
657 suggestion: Some(
658 "Use sequential heading levels".to_string(),
659 ),
660 });
661 }
662 }
663 prev_level = Some(current_level);
664 }
665 }
666}
667
668pub fn validate_wcag(
698 html: &str,
699 config: &AccessibilityConfig,
700 disable_checks: Option<&[IssueType]>,
701) -> Result<AccessibilityReport> {
702 let start_time = std::time::Instant::now();
703 let mut issues = Vec::new();
704 let mut elements_checked = 0;
705
706 if html.trim().is_empty() {
707 return Ok(AccessibilityReport {
708 issues: Vec::new(),
709 wcag_level: config.wcag_level,
710 elements_checked: 0,
711 issue_count: 0,
712 check_duration_ms: 0,
713 });
714 }
715
716 let document = Html::parse_document(html);
717
718 if disable_checks
719 .map_or(true, |d| !d.contains(&IssueType::LanguageDeclaration))
720 {
721 AccessibilityReport::check_language_attributes(
722 &document,
723 &mut issues,
724 )?;
725 }
726
727 check_heading_structure(&document, &mut issues);
729
730 elements_checked += count_checked_elements(&document);
731
732 let check_duration_ms = u64::try_from(
734 start_time.elapsed().as_millis(),
735 )
736 .map_err(|err| Error::HtmlProcessingError {
737 message: "Failed to convert duration to milliseconds"
738 .to_string(),
739 source: Some(Box::new(err)),
740 })?;
741
742 Ok(AccessibilityReport {
743 issues: issues.clone(),
744 wcag_level: config.wcag_level,
745 elements_checked,
746 issue_count: issues.len(),
747 check_duration_ms,
748 })
749}
750
751impl From<Error> for crate::error::HtmlError {
775 fn from(err: Error) -> Self {
776 use crate::error::ErrorKind as HtmlErrorKind;
777 match err {
778 Error::InvalidAriaAttribute { attribute, message } => {
779 crate::error::HtmlError::Accessibility {
780 kind: HtmlErrorKind::InvalidAriaValue,
781 message: format!("{attribute}: {message}"),
782 wcag_guideline: None,
783 }
784 }
785 Error::WcagValidationError {
786 level,
787 message,
788 guideline,
789 } => crate::error::HtmlError::Accessibility {
790 kind: HtmlErrorKind::Other,
791 message: format!("WCAG {level}: {message}"),
792 wcag_guideline: guideline,
793 },
794 Error::HtmlTooLarge { size, .. } => {
795 crate::error::HtmlError::InputTooLarge(size)
796 }
797 Error::HtmlProcessingError { message, .. } => {
798 crate::error::HtmlError::Accessibility {
799 kind: HtmlErrorKind::Other,
800 message,
801 wcag_guideline: None,
802 }
803 }
804 Error::MalformedHtml { message, fragment } => {
805 let message = match fragment {
806 Some(frag) => {
807 format!("{message} (fragment: {frag})")
808 }
809 None => message,
810 };
811 crate::error::HtmlError::Accessibility {
812 kind: HtmlErrorKind::Other,
813 message,
814 wcag_guideline: None,
815 }
816 }
817 }
818 }
819}
820
821impl From<std::num::TryFromIntError> for Error {
823 fn from(err: std::num::TryFromIntError) -> Self {
824 Error::HtmlProcessingError {
825 message: "Integer conversion error".to_string(),
826 source: Some(Box::new(err)),
827 }
828 }
829}
830
831impl std::fmt::Display for WcagLevel {
833 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
834 match self {
835 WcagLevel::A => write!(f, "A"),
836 WcagLevel::AA => write!(f, "AA"),
837 WcagLevel::AAA => write!(f, "AAA"),
838 }
839 }
840}
841
842impl AccessibilityReport {
844 fn add_issue(
846 issues: &mut Vec<Issue>,
847 issue_type: IssueType,
848 message: impl Into<String>,
849 guideline: Option<String>,
850 element: Option<String>,
851 suggestion: Option<String>,
852 ) {
853 issues.push(Issue {
854 issue_type,
855 message: message.into(),
856 guideline,
857 element,
858 suggestion,
859 });
860 }
861}
862
863static HTML_TAG_REGEX: Lazy<Regex> = Lazy::new(|| {
865 Regex::new(r"<[^>]*>").expect("static HTML_TAG_REGEX must compile")
866});
867
868static EMOJI_MAP: Lazy<HashMap<String, String>> = Lazy::new(|| {
874 if let Ok(path) = std::env::var("HTML_GENERATOR_EMOJI_DATA") {
875 match load_emoji_sequences(&path) {
876 Ok(map) => return map,
877 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
878 Err(_) => {}
879 }
880 }
881 crate::emojis::bundled_emoji_sequences()
882});
883
884fn normalize_aria_label(content: &str) -> String {
894 let no_html = HTML_TAG_REGEX.replace_all(content, "");
896 let text_only = no_html.trim();
898
899 if text_only.is_empty() {
901 return DEFAULT_BUTTON_ROLE.to_string();
902 }
903
904 for (emoji, label) in EMOJI_MAP.iter() {
907 if text_only.contains(emoji.as_str()) {
908 return label.clone();
909 }
910 }
911
912 text_only
914 .to_lowercase()
915 .replace(|c: char| !c.is_alphanumeric(), "-")
916 .replace("--", "-")
917 .trim_matches('-')
918 .to_string()
919}
920
921fn add_aria_to_tooltips(
930 mut html_builder: HtmlBuilder,
931) -> Result<HtmlBuilder> {
932 if !html_builder.content.contains("<button")
936 || !html_builder.content.contains("title=")
937 {
938 return Ok(html_builder);
939 }
940
941 let document = Html::parse_fragment(&html_builder.content);
942
943 let mut tooltip_counter = 0;
944
945 let buttons: Vec<ElementRef> =
946 document.select(&ALL_BUTTON_SELECTOR).collect();
947
948 let mut replacer = DomReplacer::new(&html_builder.content);
949
950 for button in buttons {
951 let old_button_html =
952 button.html().replace(['\n', '\r'], "").trim().to_string();
953
954 let title_attr =
955 button.value().attr("title").unwrap_or("").trim();
956 if title_attr.is_empty() {
957 continue;
958 }
959
960 tooltip_counter += 1;
961 let tooltip_id = format!("tooltip-{}", tooltip_counter);
962
963 let mut new_button_attrs = Vec::new();
964 let button_inner = button.inner_html();
965
966 for (key, val) in button.value().attrs() {
967 if key != "aria-describedby" {
968 new_button_attrs.push(format!(r#"{}="{}""#, key, val));
969 }
970 }
971
972 new_button_attrs
973 .push(format!(r#"aria-describedby="{}""#, tooltip_id));
974
975 let new_button_snippet = format!(
976 r#"<button {}>{}</button><span id="{}" role="tooltip" hidden>{}</span>"#,
977 new_button_attrs.join(" "),
978 button_inner,
979 tooltip_id,
980 title_attr
981 );
982
983 replacer.push(&old_button_html, &new_button_snippet);
984 }
985
986 html_builder.content = replacer.apply();
987 Ok(html_builder)
988}
989
990fn add_aria_to_toggle(
1009 mut html_builder: HtmlBuilder,
1010) -> Result<HtmlBuilder> {
1011 if !html_builder.content.contains("toggle-button") {
1013 return Ok(html_builder);
1014 }
1015
1016 let document = Html::parse_fragment(&html_builder.content);
1017
1018 if let Ok(selector) = Selector::parse(".toggle-button") {
1019 let mut replacer = DomReplacer::new(&html_builder.content);
1020
1021 for toggle_elem in document.select(&selector) {
1022 let old_html = toggle_elem.html();
1023 let content = toggle_elem.inner_html();
1024
1025 let mut attributes = Vec::new();
1026
1027 let old_aria_pressed = toggle_elem
1028 .value()
1029 .attr("aria-pressed")
1030 .unwrap_or("false");
1031 attributes.push(format!(
1032 r#"aria-pressed="{}""#,
1033 old_aria_pressed
1034 ));
1035
1036 attributes.push(r#"role="button""#.to_string());
1037
1038 for (key, value) in toggle_elem.value().attrs() {
1039 if key != "aria-pressed" {
1040 attributes.push(format!(r#"{}="{}""#, key, value));
1041 }
1042 }
1043
1044 let new_html = format!(
1045 r#"<button {}>{}</button>"#,
1046 attributes.join(" "),
1047 content
1048 );
1049
1050 replacer.push(&old_html, &new_html);
1051 }
1052
1053 html_builder.content = replacer.apply();
1054 }
1055
1056 Ok(html_builder)
1057}
1058
1059fn add_aria_to_buttons(
1074 mut html_builder: HtmlBuilder,
1075) -> Result<HtmlBuilder> {
1076 if !html_builder.content.contains("<button") {
1078 return Ok(html_builder);
1079 }
1080
1081 let document = Html::parse_fragment(&html_builder.content);
1082
1083 {
1085 let mut replacer = DomReplacer::new(&html_builder.content);
1086
1087 for button in document.select(&BUTTON_SELECTOR) {
1088 let original_button_html = button.html();
1089 let mut inner_content = button.inner_html();
1090 let mut aria_label = normalize_aria_label(&inner_content);
1091
1092 if inner_content.contains(r#"<span class="icon">"#) {
1094 let replacement =
1095 r#"<span class="icon" aria-hidden="true">"#;
1096 inner_content = inner_content
1097 .replace(r#"<span class="icon">"#, replacement);
1098 }
1099
1100 let mut attributes = Vec::new();
1102
1103 if button.value().attr("disabled").is_some() {
1105 attributes.push(r#"aria-disabled="true""#.to_string());
1106 } else {
1107 if let Some(current_state) =
1109 button.value().attr("aria-pressed")
1110 {
1111 let new_state = if current_state == "true" {
1112 "false"
1113 } else {
1114 "true"
1115 };
1116 attributes.push(format!(
1117 r#"aria-pressed="{}""#,
1118 new_state
1119 ));
1120 }
1121 }
1122
1123 if aria_label.is_empty() {
1125 aria_label = "button".to_string();
1126 }
1127 attributes.push(format!(r#"aria-label="{}""#, aria_label));
1128
1129 for (key, value) in button.value().attrs() {
1131 if key == "aria-pressed" {
1132 continue;
1133 }
1134 attributes.push(format!(r#"{}="{}""#, key, value));
1135 }
1136
1137 let new_button_html = format!(
1139 "<button {}>{}</button>",
1140 attributes.join(" "),
1141 inner_content
1142 );
1143
1144 replacer.push(&original_button_html, &new_button_html);
1146 }
1147
1148 html_builder.content = replacer.apply();
1149 }
1150
1151 Ok(html_builder)
1152}
1153
1154struct DomReplacer {
1164 source: String,
1166 queue: Vec<(String, String)>,
1169}
1170
1171impl DomReplacer {
1172 fn new(source: &str) -> Self {
1173 Self {
1174 source: source.to_string(),
1175 queue: Vec::new(),
1176 }
1177 }
1178
1179 fn push(&mut self, old_html: &str, new_html: &str) {
1180 self.queue
1181 .push((old_html.to_string(), new_html.to_string()));
1182 }
1183
1184 fn apply(self) -> String {
1188 let mut resolved: Vec<(usize, usize, String)> = Vec::new();
1194 let mut offset_map: HashMap<String, usize> = HashMap::new();
1197
1198 let source = &self.source;
1199
1200 for (old_html, new_html) in &self.queue {
1201 let search_start =
1202 offset_map.get(old_html).copied().unwrap_or(0);
1203
1204 if let Some(pos) =
1206 source[search_start..].find(old_html.as_str())
1207 {
1208 let abs = search_start + pos;
1209 resolved.push((abs, old_html.len(), new_html.clone()));
1210 let _ = offset_map
1211 .insert(old_html.clone(), abs + old_html.len());
1212 continue;
1213 }
1214
1215 let normalized_old = SHORTHAND_ATTR_REGEX.replace_all(
1217 old_html,
1218 |caps: ®ex::Captures| {
1219 format!(r#"{}=""{}"#, &caps[1], &caps[2])
1220 },
1221 );
1222 let normalized_src = SHORTHAND_ATTR_REGEX.replace_all(
1223 &source[search_start..],
1224 |caps: ®ex::Captures| {
1225 format!(r#"{}=""{}"#, &caps[1], &caps[2])
1226 },
1227 );
1228
1229 if let Some(pos) =
1230 normalized_src.find(normalized_old.as_ref())
1231 {
1232 let approx_abs = search_start + pos;
1236 let tag_open = extract_opening_tag(old_html);
1238 if let Some(anchor_pos) =
1239 source[search_start..].find(&tag_open)
1240 {
1241 let abs = search_start + anchor_pos;
1242 if let Some(end_offset) =
1244 find_element_end(source, abs, old_html)
1245 {
1246 resolved.push((
1247 abs,
1248 end_offset - abs,
1249 new_html.clone(),
1250 ));
1251 let _ = offset_map
1252 .insert(old_html.clone(), end_offset);
1253 continue;
1254 }
1255 }
1256 resolved.push((
1258 approx_abs.min(source.len()),
1259 old_html
1260 .len()
1261 .min(source.len().saturating_sub(approx_abs)),
1262 new_html.clone(),
1263 ));
1264 let _ = offset_map.insert(
1265 old_html.clone(),
1266 approx_abs + old_html.len(),
1267 );
1268 continue;
1269 }
1270
1271 if let Some(pos) =
1274 source[search_start..].find(old_html.as_str())
1275 {
1276 let abs = search_start + pos;
1277 resolved.push((abs, old_html.len(), new_html.clone()));
1278 let _ = offset_map
1279 .insert(old_html.clone(), abs + old_html.len());
1280 }
1281 }
1283
1284 resolved.sort_by_key(|r| std::cmp::Reverse(r.0));
1288
1289 let mut result = source.to_string();
1290 for (offset, len, replacement) in resolved {
1291 if offset + len <= result.len() {
1292 result
1293 .replace_range(offset..offset + len, &replacement);
1294 }
1295 }
1296
1297 result
1298 }
1299}
1300
1301fn extract_opening_tag(html: &str) -> String {
1303 let trimmed = html.trim_start_matches('<');
1304 if let Some(end) = trimmed
1305 .find(|c: char| c.is_whitespace() || c == '>' || c == '/')
1306 {
1307 format!("<{}", &trimmed[..end])
1308 } else {
1309 format!("<{trimmed}")
1310 }
1311}
1312
1313fn find_element_end(
1316 source: &str,
1317 start: usize,
1318 element_html: &str,
1319) -> Option<usize> {
1320 let tag_name = extract_opening_tag(element_html)
1322 .trim_start_matches('<')
1323 .to_string();
1324
1325 let closing = format!("</{tag_name}>");
1327 if let Some(close_pos) = source[start..].find(&closing) {
1328 Some(start + close_pos + closing.len())
1329 } else {
1330 source[start..].find('>').map(|p| start + p + 1)
1332 }
1333}
1334
1335fn replace_element(
1343 document_html: &str,
1344 old_element_html: &str,
1345 new_element_html: &str,
1346) -> String {
1347 let mut replacer = DomReplacer::new(document_html);
1348 replacer.push(old_element_html, new_element_html);
1349 replacer.apply()
1350}
1351
1352#[cfg(test)]
1353fn normalize_shorthand_attributes(html: &str) -> String {
1354 SHORTHAND_ATTR_REGEX
1355 .replace_all(html, |caps: ®ex::Captures| {
1356 let attr = &caps[1]; let delim = &caps[2]; format!(r#"{}=""{}"#, attr, delim)
1363 })
1364 .to_string()
1365}
1366
1367fn add_aria_to_navs(
1369 mut html_builder: HtmlBuilder,
1370) -> Result<HtmlBuilder> {
1371 if !html_builder.content.contains("<nav") {
1373 return Ok(html_builder);
1374 }
1375
1376 let document = Html::parse_fragment(&html_builder.content);
1377
1378 {
1379 let mut replacer = DomReplacer::new(&html_builder.content);
1380
1381 for nav in document.select(&NAV_SELECTOR) {
1382 let nav_html = nav.html();
1383 let new_nav_html = nav_html.replace(
1384 "<nav",
1385 &format!(
1386 r#"<nav aria-label="{}" role="navigation""#,
1387 DEFAULT_NAV_ROLE
1388 ),
1389 );
1390 replacer.push(&nav_html, &new_nav_html);
1391 }
1392
1393 html_builder.content = replacer.apply();
1394 }
1395
1396 Ok(html_builder)
1397}
1398
1399fn add_aria_to_forms(
1401 mut html_builder: HtmlBuilder,
1402) -> Result<HtmlBuilder> {
1403 if !html_builder.content.contains("<form") {
1405 return Ok(html_builder);
1406 }
1407
1408 let document = Html::parse_fragment(&html_builder.content);
1409
1410 let mut replacer = DomReplacer::new(&html_builder.content);
1412 let forms = document.select(&FORM_SELECTOR);
1413 let mut form_counter: u32 = 0;
1414 for form in forms {
1415 form_counter += 1;
1416 let form_id = format!("form-{form_counter}");
1419
1420 let form_element = form.value().clone();
1421 let mut attributes = form_element.attrs().collect::<Vec<_>>();
1422
1423 if !attributes.iter().any(|&(k, _)| k == "id") {
1425 attributes.push(("id", &*form_id));
1426 }
1427
1428 if !attributes.iter().any(|&(k, _)| k == "aria-labelledby") {
1430 attributes.push(("aria-labelledby", &*form_id));
1431 }
1432
1433 let new_form_html = format!(
1435 "<form {}>{}</form>",
1436 attributes
1437 .iter()
1438 .map(|&(k, v)| format!(r#"{}="{}""#, k, v))
1439 .collect::<Vec<_>>()
1440 .join(" "),
1441 form.inner_html()
1442 );
1443
1444 replacer.push(&form.html(), &new_form_html);
1445 }
1446
1447 html_builder.content = replacer.apply();
1448 Ok(html_builder)
1449}
1450
1451fn add_aria_to_tabs(
1462 mut html_builder: HtmlBuilder,
1463) -> Result<HtmlBuilder> {
1464 if !html_builder.content.contains("tablist") {
1467 return Ok(html_builder);
1468 }
1469
1470 let document = Html::parse_fragment(&html_builder.content);
1471
1472 if let Ok(tablist_selector) = Selector::parse("[role='tablist']") {
1473 let mut replacer = DomReplacer::new(&html_builder.content);
1474
1475 for tablist in document.select(&tablist_selector) {
1476 let tablist_html = tablist.html();
1477
1478 let mut new_html = String::new();
1479 new_html.push_str("<div role=\"tablist\">");
1480
1481 let mut button_texts = Vec::new();
1482 if let Ok(button_selector) = Selector::parse("button") {
1483 for button in tablist.select(&button_selector) {
1484 button_texts.push(button.inner_html());
1485 }
1486 }
1487
1488 for (i, text) in button_texts.iter().enumerate() {
1489 let is_selected = i == 0;
1490 let num = i + 1;
1491 new_html.push_str(&format!(
1492 r#"<button role="tab" id="tab{}" aria-selected="{}" aria-controls="panel{}" tabindex="{}">{}</button>"#,
1493 num,
1494 is_selected,
1495 num,
1496 if is_selected { "0" } else { "-1" },
1497 text
1498 ));
1499 }
1500 new_html.push_str("</div>");
1501
1502 for i in 0..button_texts.len() {
1503 let num = i + 1;
1504 let maybe_hidden = if i == 0 { "" } else { "hidden" };
1505 new_html.push_str(&format!(
1506 r#"<div id="panel{}" role="tabpanel" aria-labelledby="tab{}"{}>Panel {}</div>"#,
1507 num,
1508 num,
1509 maybe_hidden,
1510 num
1511 ));
1512 }
1513
1514 replacer.push(&tablist_html, &new_html);
1515 }
1516
1517 html_builder.content = replacer.apply();
1518 }
1519
1520 Ok(html_builder)
1521}
1522
1523fn add_aria_to_modals(
1531 mut html_builder: HtmlBuilder,
1532) -> Result<HtmlBuilder> {
1533 if !html_builder.content.contains("modal") {
1535 return Ok(html_builder);
1536 }
1537
1538 let document = Html::parse_fragment(&html_builder.content);
1540
1541 let modal_selector = match Selector::parse(".modal") {
1543 Ok(s) => s,
1544 Err(_) => {
1545 return Ok(html_builder); }
1547 };
1548
1549 let mut replacer = DomReplacer::new(&html_builder.content);
1551 let mut modal_counter: u32 = 0;
1552
1553 for modal_elem in document.select(&modal_selector) {
1554 modal_counter += 1;
1555 let old_modal_html = modal_elem.html();
1556
1557 let mut new_attrs = Vec::new();
1558 let mut found_role = false;
1559 let mut found_aria_modal = false;
1560 let mut existing_role_value = String::new();
1561
1562 for (attr_name, attr_value) in modal_elem.value().attrs() {
1563 if attr_name.eq_ignore_ascii_case("role") {
1564 found_role = true;
1565 existing_role_value = attr_value.to_string();
1566 new_attrs
1567 .push(format!(r#"{}="{}""#, attr_name, attr_value));
1568 } else if attr_name.eq_ignore_ascii_case("aria-modal") {
1569 found_aria_modal = true;
1570 new_attrs
1571 .push(format!(r#"{}="{}""#, attr_name, attr_value));
1572 } else {
1573 new_attrs
1574 .push(format!(r#"{}="{}""#, attr_name, attr_value));
1575 }
1576 }
1577
1578 let is_alert_dialog = existing_role_value
1579 .eq_ignore_ascii_case("alertdialog")
1580 || modal_elem.value().has_class(
1581 "alert",
1582 CaseSensitivity::AsciiCaseInsensitive,
1583 );
1584
1585 if !found_role || existing_role_value.trim().is_empty() {
1586 if is_alert_dialog {
1587 new_attrs.push(r#"role="alertdialog""#.to_string());
1588 } else {
1589 new_attrs.push(r#"role="dialog""#.to_string());
1590 }
1591 }
1592
1593 if !found_aria_modal {
1594 new_attrs.push(r#"aria-modal="true""#.to_string());
1595 }
1596
1597 let mut doc_inner =
1599 Html::parse_fragment(&modal_elem.inner_html());
1600 let mut maybe_describedby = None;
1601
1602 if let Some(descriptive_elem) =
1603 doc_inner.select(&DIALOG_DESC_SELECTOR).next()
1604 {
1605 let desc_id: String = if let Some(id_val) =
1606 descriptive_elem.value().attr("id")
1607 {
1608 id_val.to_string()
1609 } else {
1610 let generated_id =
1611 format!("dialog-desc-{modal_counter}");
1612 let old_snippet = descriptive_elem.html();
1613
1614 let new_opening_tag = format!(
1615 r#"<{} id="{}""#,
1616 descriptive_elem.value().name(),
1617 generated_id
1618 );
1619
1620 let rest_of_tag = old_snippet
1621 .strip_prefix(&format!(
1622 "<{}",
1623 descriptive_elem.value().name()
1624 ))
1625 .unwrap_or("");
1626
1627 let new_snippet =
1628 format!("{}{}", new_opening_tag, rest_of_tag);
1629
1630 let updated_inner = replace_element(
1631 &modal_elem.inner_html(),
1632 &old_snippet,
1633 &new_snippet,
1634 );
1635 doc_inner = Html::parse_fragment(&updated_inner);
1636
1637 generated_id
1638 };
1639
1640 maybe_describedby = Some(desc_id);
1641 }
1642
1643 let already_has_describedby = new_attrs
1644 .iter()
1645 .any(|attr| attr.starts_with("aria-describedby"));
1646
1647 if let Some(desc_id) = maybe_describedby {
1648 if !already_has_describedby {
1649 new_attrs
1650 .push(format!(r#"aria-describedby="{}""#, desc_id));
1651 }
1652 }
1653
1654 let children_html = doc_inner.root_element().inner_html();
1655
1656 let new_modal_html = format!(
1657 r#"<div {}>{}</div>"#,
1658 new_attrs.join(" "),
1659 children_html
1660 );
1661
1662 replacer.push(&old_modal_html, &new_modal_html);
1663 }
1664
1665 html_builder.content = replacer.apply();
1666 Ok(html_builder)
1667}
1668
1669fn add_aria_to_accordions(
1670 mut html_builder: HtmlBuilder,
1671) -> Result<HtmlBuilder> {
1672 if !html_builder.content.contains("accordion") {
1674 return Ok(html_builder);
1675 }
1676
1677 let document = Html::parse_fragment(&html_builder.content);
1678
1679 if let Ok(accordion_selector) = Selector::parse(".accordion") {
1680 let mut replacer = DomReplacer::new(&html_builder.content);
1681
1682 for accordion in document.select(&accordion_selector) {
1683 let accordion_html = accordion.html();
1684 let mut new_html =
1685 String::from("<div class=\"accordion\">");
1686
1687 if let (Ok(button_selector), Ok(content_selector)) = (
1688 Selector::parse("button"),
1689 Selector::parse("button + div"),
1690 ) {
1691 let buttons = accordion.select(&button_selector);
1692 let contents = accordion.select(&content_selector);
1693
1694 for (i, (button, content)) in
1695 buttons.zip(contents).enumerate()
1696 {
1697 let button_text = button.inner_html();
1698 let content_text = content.inner_html();
1699 let section_num = i + 1;
1700
1701 new_html.push_str(&format!(
1702 r#"<button aria-expanded="false" aria-controls="section-{}-content" id="section-{}-button">{}</button><div id="section-{}-content" aria-labelledby="section-{}-button" hidden>{}</div>"#,
1703 section_num, section_num, button_text,
1704 section_num, section_num, content_text
1705 ));
1706 }
1707 }
1708
1709 new_html.push_str("</div>");
1710 replacer.push(&accordion_html, &new_html);
1711 }
1712
1713 html_builder.content = replacer.apply();
1714 }
1715
1716 Ok(html_builder)
1717}
1718
1719fn add_aria_to_inputs(
1721 mut html_builder: HtmlBuilder,
1722) -> Result<HtmlBuilder> {
1723 if !html_builder.content.contains("<input") {
1725 return Ok(html_builder);
1726 }
1727 {
1728 let mut replacements: Vec<(String, String)> = Vec::new();
1729 let mut id_counter = 0;
1730
1731 for cap in INPUT_REGEX.captures_iter(&html_builder.content) {
1733 let input_tag = &cap[0];
1734
1735 if input_tag.contains("aria-label")
1737 || has_associated_label(
1738 input_tag,
1739 &html_builder.content,
1740 )
1741 {
1742 continue;
1743 }
1744
1745 let input_type = extract_input_type(input_tag)
1747 .unwrap_or_else(|| "text".to_string());
1748
1749 match input_type.as_str() {
1750 "text" | "search" | "tel" | "url" | "email"
1752 | "password" | "hidden" | "submit" | "reset"
1753 | "button" | "image" => {
1754 }
1756
1757 "checkbox" | "radio" => {
1759 let attributes = preserve_attributes(input_tag);
1761
1762 if let Some(id_match) =
1764 ID_ATTR_REGEX.captures(&attributes)
1765 {
1766 let existing_id = &id_match[1];
1768 let attributes_no_id = ID_ATTR_REGEX
1771 .replace(&attributes, "")
1772 .to_string();
1773
1774 let label_text = if input_type == "checkbox" {
1776 format!("Checkbox for {}", existing_id)
1777 } else {
1778 "Option".to_string()
1779 };
1780
1781 let enhanced_input = format!(
1783 r#"<{} id="{}"><label for="{}">{}</label>"#,
1784 attributes_no_id.trim(),
1785 existing_id,
1786 existing_id,
1787 label_text
1788 );
1789 replacements.push((
1790 input_tag.to_string(),
1791 enhanced_input,
1792 ));
1793 } else {
1794 id_counter += 1;
1796 let new_id = format!("option{}", id_counter);
1797 let label_text = if input_type == "checkbox" {
1798 "Checkbox".to_string()
1799 } else {
1800 format!("Option {}", id_counter)
1801 };
1802
1803 let enhanced_input = format!(
1804 r#"<{} id="{}"><label for="{}">{}</label>"#,
1805 attributes, new_id, new_id, label_text
1806 );
1807 replacements.push((
1808 input_tag.to_string(),
1809 enhanced_input,
1810 ));
1811 }
1812 }
1813
1814 _ => {
1816 let attributes = preserve_attributes(input_tag);
1817 let enhanced_input = format!(
1818 r#"<input {} aria-label="{}">"#,
1819 attributes, input_type
1820 );
1821 replacements
1822 .push((input_tag.to_string(), enhanced_input));
1823 }
1824 }
1825 }
1826
1827 let mut replacer = DomReplacer::new(&html_builder.content);
1829 for (old, new) in replacements {
1830 replacer.push(&old, &new);
1831 }
1832 html_builder.content = replacer.apply();
1833 }
1834
1835 Ok(html_builder)
1836}
1837
1838fn has_associated_label(input_tag: &str, html_content: &str) -> bool {
1840 if let Some(id_match) = INPUT_ID_REGEX.captures(input_tag) {
1841 let id = &id_match[1];
1842 Regex::new(&format!(
1843 r#"<label\s+for="{}"\s*>"#,
1844 regex::escape(id)
1845 ))
1846 .is_ok_and(|r| r.is_match(html_content))
1847 } else {
1848 false
1849 }
1850}
1851
1852static ATTRIBUTE_REGEX: Lazy<Regex> = Lazy::new(|| {
1854 try_create_regex(
1855 r#"(?:data-\w+|[a-zA-Z]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\S+))?"#,
1856 )
1857});
1858
1859fn preserve_attributes(input_tag: &str) -> String {
1861 ATTRIBUTE_REGEX
1862 .captures_iter(input_tag)
1863 .map(|cap| cap[0].to_string())
1864 .collect::<Vec<String>>()
1865 .join(" ")
1866}
1867
1868fn extract_input_type(input_tag: &str) -> Option<String> {
1870 static TYPE_REGEX: Lazy<Regex> = Lazy::new(|| {
1871 Regex::new(r#"type=["']([^"']+)["']"#)
1872 .expect("static TYPE_REGEX must compile")
1873 });
1874
1875 TYPE_REGEX
1876 .captures(input_tag)
1877 .and_then(|cap| cap.get(1))
1878 .map(|m| m.as_str().to_string())
1879}
1880
1881#[cfg(test)]
1889fn generate_unique_id() -> String {
1890 use std::sync::atomic::{AtomicU64, Ordering};
1891 static COUNTER: AtomicU64 = AtomicU64::new(0);
1892 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1893 format!("aria-{n}")
1894}
1895
1896fn remove_invalid_aria_attributes(html: &str) -> String {
1897 let document = Html::parse_fragment(html);
1901 let mut replacer: Option<DomReplacer> = None;
1902
1903 for element in document.select(&ARIA_SELECTOR) {
1904 let has_invalid = element.value().attrs().any(|(n, v)| {
1906 n.starts_with("aria-") && !is_valid_aria_attribute(n, v)
1907 });
1908 if !has_invalid {
1909 continue;
1910 }
1911
1912 let element_html = element.html();
1913 let mut updated_html = element_html.clone();
1914 for (attr_name, attr_value) in element.value().attrs() {
1915 if attr_name.starts_with("aria-")
1916 && !is_valid_aria_attribute(attr_name, attr_value)
1917 {
1918 updated_html = updated_html.replace(
1919 &format!(r#" {}="{}""#, attr_name, attr_value),
1920 "",
1921 );
1922 }
1923 }
1924
1925 replacer
1926 .get_or_insert_with(|| DomReplacer::new(html))
1927 .push(&element_html, &updated_html);
1928 }
1929
1930 match replacer {
1933 Some(r) => r.apply(),
1934 None => html.to_string(),
1935 }
1936}
1937
1938fn is_valid_aria_attribute(name: &str, value: &str) -> bool {
1940 if !VALID_ARIA_ATTRIBUTES.contains(name) {
1941 return false; }
1943
1944 match name {
1945 "aria-hidden" | "aria-expanded" | "aria-pressed"
1946 | "aria-invalid" => {
1947 matches!(value, "true" | "false") }
1949 _ => !value.trim().is_empty(), }
1956}
1957
1958impl AccessibilityReport {
1960 pub fn check_keyboard_navigation(
1987 document: &Html,
1988 issues: &mut Vec<Issue>,
1989 ) -> Result<()> {
1990 let interactive_elements =
1991 document.select(&INTERACTIVE_SELECTOR);
1992
1993 for element in interactive_elements {
1994 if let Some(tabindex) = element.value().attr("tabindex") {
1996 if let Ok(index) = tabindex.parse::<i32>() {
1997 if index < 0 {
1998 issues.push(Issue {
1999 issue_type: IssueType::KeyboardNavigation,
2000 message: "Negative tabindex prevents keyboard focus".to_string(),
2001 guideline: Some("WCAG 2.1.1".to_string()),
2002 element: Some(element.html()),
2003 suggestion: Some("Remove negative tabindex value".to_string()),
2004 });
2005 }
2006 }
2007 }
2008
2009 if element.value().attr("onclick").is_some()
2011 && element.value().attr("onkeypress").is_none()
2012 && element.value().attr("onkeydown").is_none()
2013 {
2014 issues.push(Issue {
2015 issue_type: IssueType::KeyboardNavigation,
2016 message:
2017 "Click handler without keyboard equivalent"
2018 .to_string(),
2019 guideline: Some("WCAG 2.1.1".to_string()),
2020 element: Some(element.html()),
2021 suggestion: Some(
2022 "Add keyboard event handlers".to_string(),
2023 ),
2024 });
2025 }
2026 }
2027 Ok(())
2028 }
2029
2030 pub fn check_language_attributes(
2053 document: &Html,
2054 issues: &mut Vec<Issue>,
2055 ) -> Result<()> {
2056 let html_element = document.select(&HTML_SELECTOR).next();
2058 if let Some(element) = html_element {
2059 if element.value().attr("lang").is_none() {
2060 Self::add_issue(
2061 issues,
2062 IssueType::LanguageDeclaration,
2063 "Missing language declaration",
2064 Some("WCAG 3.1.1".to_string()),
2065 Some(element.html()),
2066 Some(
2067 "Add lang attribute to html element"
2068 .to_string(),
2069 ),
2070 );
2071 }
2072 }
2073
2074 let text_elements = document.select(&LANG_SELECTOR);
2076 for element in text_elements {
2077 if let Some(lang) = element.value().attr("lang") {
2078 if !is_valid_language_code(lang) {
2079 Self::add_issue(
2080 issues,
2081 IssueType::LanguageDeclaration,
2082 format!("Invalid language code: {}", lang),
2083 Some("WCAG 3.1.2".to_string()),
2084 Some(element.html()),
2085 Some(
2086 "Use valid BCP 47 language code"
2087 .to_string(),
2088 ),
2089 );
2090 }
2091 }
2092 }
2093 Ok(())
2094 }
2095
2096 pub fn check_advanced_aria(
2129 document: &Html,
2130 issues: &mut Vec<Issue>,
2131 ) -> Result<()> {
2132 for element in document.select(&ROLE_SELECTOR) {
2134 if let Some(role) = element.value().attr("role") {
2135 if !is_valid_aria_role(role, &element) {
2136 Self::add_issue(
2137 issues,
2138 IssueType::InvalidAria,
2139 format!(
2140 "Invalid ARIA role '{}' for element",
2141 role
2142 ),
2143 Some("WCAG 4.1.2".to_string()),
2144 Some(element.html()),
2145 Some("Use appropriate ARIA role".to_string()),
2146 );
2147 }
2148 }
2149 }
2150
2151 for element in document.select(&ARIA_SELECTOR) {
2153 if let Some(missing_props) =
2154 get_missing_required_aria_properties(&element)
2155 {
2156 Self::add_issue(
2157 issues,
2158 IssueType::InvalidAria,
2159 format!(
2160 "Missing required ARIA properties: {}",
2161 missing_props.join(", ")
2162 ),
2163 Some("WCAG 4.1.2".to_string()),
2164 Some(element.html()),
2165 Some("Add required ARIA properties".to_string()),
2166 );
2167 }
2168 }
2169 Ok(())
2170 }
2171}
2172
2173pub mod utils {
2186 use scraper::ElementRef;
2187 use std::collections::HashMap;
2188
2189 use once_cell::sync::Lazy;
2191 use regex::Regex;
2192
2193 pub(crate) fn is_valid_language_code(lang: &str) -> bool {
2195 static LANGUAGE_CODE_REGEX: Lazy<Regex> = Lazy::new(|| {
2196 Regex::new(r"(?i)^[a-z]{2,3}(-[a-z0-9]{2,8})*$")
2198 .expect("static LANGUAGE_CODE_REGEX must compile")
2199 });
2200
2201 LANGUAGE_CODE_REGEX.is_match(lang) && !lang.ends_with('-')
2203 }
2204
2205 pub(crate) fn is_valid_aria_role(
2207 role: &str,
2208 element: &ElementRef,
2209 ) -> bool {
2210 static VALID_ROLES: Lazy<HashMap<&str, Vec<&str>>> =
2211 Lazy::new(|| {
2212 let mut map = HashMap::new();
2213 _ = map.insert(
2214 "button",
2215 vec!["button", "link", "menuitem"],
2216 );
2217 _ = map.insert(
2218 "input",
2219 vec!["textbox", "radio", "checkbox", "button"],
2220 );
2221 _ = map.insert(
2222 "div",
2223 vec!["alert", "tooltip", "dialog", "slider"],
2224 );
2225 _ = map.insert("a", vec!["link", "button", "menuitem"]);
2226 map
2227 });
2228
2229 let tag_name = element.value().name();
2231 if ["div", "span", "a"].contains(&tag_name) {
2232 return true;
2233 }
2234
2235 if let Some(valid_roles) = VALID_ROLES.get(tag_name) {
2237 valid_roles.contains(&role)
2238 } else {
2239 false
2240 }
2241 }
2242
2243 pub(crate) fn get_missing_required_aria_properties(
2245 element: &ElementRef,
2246 ) -> Option<Vec<String>> {
2247 let mut missing = Vec::new();
2248
2249 static REQUIRED_ARIA_PROPS: Lazy<HashMap<&str, Vec<&str>>> =
2250 Lazy::new(|| {
2251 HashMap::from([
2252 (
2253 "slider",
2254 vec![
2255 "aria-valuenow",
2256 "aria-valuemin",
2257 "aria-valuemax",
2258 ],
2259 ),
2260 ("combobox", vec!["aria-expanded"]),
2261 ])
2262 });
2263
2264 if let Some(role) = element.value().attr("role") {
2265 if let Some(required_props) = REQUIRED_ARIA_PROPS.get(role)
2266 {
2267 for prop in required_props {
2268 if element.value().attr(prop).is_none() {
2269 missing.push((*prop).to_string());
2270 }
2271 }
2272 }
2273 }
2274
2275 if missing.is_empty() {
2276 None
2277 } else {
2278 Some(missing)
2279 }
2280 }
2281}
2282
2283#[cfg(test)]
2284mod tests {
2285 use super::*;
2286
2287 mod dom_replacer_fallbacks {
2290 use super::*;
2291
2292 #[test]
2293 fn direct_match_hits_the_fast_path() {
2294 let mut r = DomReplacer::new("<p>one</p><p>two</p>");
2295 r.push("<p>one</p>", "<p>ONE</p>");
2296 r.push("<p>two</p>", "<p>TWO</p>");
2297 assert_eq!(r.apply(), "<p>ONE</p><p>TWO</p>");
2298 }
2299
2300 #[test]
2301 fn shorthand_fallback_anchors_via_tag_name() {
2302 let source = r#"<form><input disabled type="text"></form>"#;
2307 let mut r = DomReplacer::new(source);
2308 r.push(
2309 r#"<input disabled="" type="text">"#,
2310 r#"<input disabled aria-disabled="true" type="text">"#,
2311 );
2312 let out = r.apply();
2313 assert!(
2314 out.contains("aria-disabled"),
2315 "shorthand fallback should patch source: {out}"
2316 );
2317 }
2318
2319 #[test]
2320 fn last_resort_path_fires_when_fallback_misses() {
2321 let mut r = DomReplacer::new("<p>unchanged</p>");
2326 r.push("<nonexistent></nonexistent>", "<whatever/>");
2327 assert_eq!(r.apply(), "<p>unchanged</p>");
2328 }
2329
2330 #[test]
2331 fn duplicate_identical_snippets_map_to_distinct_occurrences() {
2332 let source = "<span>x</span><span>x</span>";
2336 let mut r = DomReplacer::new(source);
2337 r.push("<span>x</span>", "<span>A</span>");
2338 r.push("<span>x</span>", "<span>B</span>");
2339 assert_eq!(r.apply(), "<span>A</span><span>B</span>");
2340 }
2341
2342 #[test]
2343 fn extract_opening_tag_handles_self_terminating_input() {
2344 assert_eq!(
2347 extract_opening_tag(r#"<button id="x">click</button>"#),
2348 "<button"
2349 );
2350 assert_eq!(extract_opening_tag(r#"<input/>"#), "<input");
2352 assert_eq!(extract_opening_tag("<naked"), "<naked");
2356 assert_eq!(extract_opening_tag("naked"), "<naked");
2357 }
2358
2359 #[test]
2360 fn shorthand_normalization_fires_closure_on_queued_old_html() {
2361 let source =
2369 r#"<form><input disabled="" type="text"></form>"#;
2370 let mut r = DomReplacer::new(source);
2371 r.push(
2372 r#"<input disabled type="text">"#,
2373 r#"<input disabled aria-disabled="true" type="text">"#,
2374 );
2375 let out = r.apply();
2376 assert!(
2381 out.contains("aria-disabled") || out == source,
2382 "shorthand closure on old_html should have run: {out}"
2383 );
2384 }
2385 }
2386
2387 mod wcag_level_tests {
2389 use super::*;
2390
2391 #[test]
2392 fn test_wcag_level_ordering() {
2393 assert!(matches!(WcagLevel::A, WcagLevel::A));
2394 assert!(matches!(WcagLevel::AA, WcagLevel::AA));
2395 assert!(matches!(WcagLevel::AAA, WcagLevel::AAA));
2396 }
2397
2398 #[test]
2399 fn test_wcag_level_debug() {
2400 assert_eq!(format!("{:?}", WcagLevel::A), "A");
2401 assert_eq!(format!("{:?}", WcagLevel::AA), "AA");
2402 assert_eq!(format!("{:?}", WcagLevel::AAA), "AAA");
2403 }
2404 }
2405
2406 mod config_tests {
2408 use super::*;
2409
2410 #[test]
2411 fn test_default_config() {
2412 let config = AccessibilityConfig::default();
2413 assert_eq!(config.wcag_level, WcagLevel::AA);
2414 assert_eq!(config.max_heading_jump, 1);
2415 assert_eq!(config.min_contrast_ratio, 4.5);
2416 assert!(config.auto_fix);
2417 }
2418
2419 #[test]
2420 fn test_custom_config() {
2421 let config = AccessibilityConfig {
2422 wcag_level: WcagLevel::AAA,
2423 max_heading_jump: 2,
2424 min_contrast_ratio: 7.0,
2425 auto_fix: false,
2426 };
2427 assert_eq!(config.wcag_level, WcagLevel::AAA);
2428 assert_eq!(config.max_heading_jump, 2);
2429 assert_eq!(config.min_contrast_ratio, 7.0);
2430 assert!(!config.auto_fix);
2431 }
2432 }
2433
2434 mod aria_attribute_tests {
2436 use super::*;
2437
2438 #[test]
2439 fn test_valid_aria_attributes() {
2440 assert!(is_valid_aria_attribute("aria-label", "Test"));
2441 assert!(is_valid_aria_attribute("aria-hidden", "true"));
2442 assert!(is_valid_aria_attribute("aria-hidden", "false"));
2443 assert!(!is_valid_aria_attribute("aria-hidden", "yes"));
2444 assert!(!is_valid_aria_attribute("invalid-aria", "value"));
2445 }
2446
2447 #[test]
2448 fn test_empty_aria_value() {
2449 assert!(!is_valid_aria_attribute("aria-label", ""));
2450 assert!(!is_valid_aria_attribute("aria-label", " "));
2451 }
2452 }
2453
2454 mod html_modification_tests {
2456 use super::*;
2457
2458 #[test]
2459 fn test_add_aria_to_empty_button() {
2460 let html = "<button></button>";
2461 let result = add_aria_attributes(html, None);
2462 assert!(result.is_ok());
2463 let enhanced = result.unwrap();
2464 assert!(enhanced.contains(r#"aria-label="button""#));
2465 }
2466
2467 #[test]
2468 fn test_large_input() {
2469 let large_html = "a".repeat(MAX_HTML_SIZE + 1);
2470 let result = add_aria_attributes(&large_html, None);
2471 assert!(matches!(result, Err(Error::HtmlTooLarge { .. })));
2472 }
2473 }
2474
2475 mod validation_tests {
2477 use super::*;
2478
2479 #[test]
2480 fn test_heading_structure() {
2481 let valid_html = "<h1>Main Title</h1><h2>Subtitle</h2>";
2482 let invalid_html =
2483 "<h1>Main Title</h1><h3>Skipped Heading</h3>";
2484
2485 let config = AccessibilityConfig::default();
2486
2487 let valid_result = validate_wcag(
2489 valid_html,
2490 &config,
2491 Some(&[IssueType::LanguageDeclaration]),
2492 )
2493 .unwrap();
2494 assert_eq!(
2495 valid_result.issue_count, 0,
2496 "Expected no issues for valid HTML, but found: {:#?}",
2497 valid_result.issues
2498 );
2499
2500 let invalid_result = validate_wcag(
2502 invalid_html,
2503 &config,
2504 Some(&[IssueType::LanguageDeclaration]),
2505 )
2506 .unwrap();
2507 assert_eq!(
2508 invalid_result.issue_count,
2509 1,
2510 "Expected one issue for skipped heading levels, but found: {:#?}",
2511 invalid_result.issues
2512 );
2513
2514 let issue = &invalid_result.issues[0];
2515 assert_eq!(issue.issue_type, IssueType::HeadingStructure);
2516 assert_eq!(
2517 issue.message,
2518 "Skipped heading level from h1 to h3"
2519 );
2520 assert_eq!(issue.guideline, Some("WCAG 2.4.6".to_string()));
2521 assert_eq!(
2522 issue.suggestion,
2523 Some("Use sequential heading levels".to_string())
2524 );
2525 }
2526 }
2527
2528 mod report_tests {
2530 use super::*;
2531
2532 #[test]
2533 fn test_report_generation() {
2534 let html = r#"<img src="test.jpg">"#;
2535 let config = AccessibilityConfig::default();
2536 let report = validate_wcag(html, &config, None).unwrap();
2537
2538 assert!(report.issue_count > 0);
2539
2540 assert_eq!(report.wcag_level, WcagLevel::AA);
2541 }
2542
2543 #[test]
2544 fn test_empty_html_report() {
2545 let html = "";
2546 let config = AccessibilityConfig::default();
2547 let report = validate_wcag(html, &config, None).unwrap();
2548
2549 assert_eq!(report.elements_checked, 0);
2550 assert_eq!(report.issue_count, 0);
2551 }
2552
2553 #[test]
2554 fn test_missing_selector_handling() {
2555 static TEST_NAV_SELECTOR: Lazy<Option<Selector>> =
2557 Lazy::new(|| None);
2558
2559 let html = "<nav>Main Navigation</nav>";
2560 let document = Html::parse_document(html);
2561
2562 if let Some(selector) = TEST_NAV_SELECTOR.as_ref() {
2563 let navs: Vec<_> = document.select(selector).collect();
2564 assert_eq!(navs.len(), 0);
2565 }
2566 }
2567
2568 #[test]
2569 fn test_html_processing_error_with_source() {
2570 let source_error =
2571 std::io::Error::other("test source error");
2572 let error = Error::HtmlProcessingError {
2573 message: "Processing failed".to_string(),
2574 source: Some(Box::new(source_error)),
2575 };
2576
2577 assert_eq!(
2578 format!("{}", error),
2579 "HTML Processing Error: Processing failed"
2580 );
2581 }
2582 }
2583 #[cfg(test)]
2584 mod utils_tests {
2585 use super::*;
2586
2587 mod language_code_validation {
2588 use super::*;
2589
2590 #[test]
2591 fn test_valid_language_codes() {
2592 let valid_codes = [
2593 "en", "en-US", "zh-CN", "fr-FR", "de-DE", "es-419",
2594 "ar-001", "pt-BR", "ja-JP", "ko-KR",
2595 ];
2596 for code in valid_codes {
2597 assert!(
2598 is_valid_language_code(code),
2599 "Language code '{}' should be valid",
2600 code
2601 );
2602 }
2603 }
2604
2605 #[test]
2606 fn test_invalid_language_codes() {
2607 let invalid_codes = [
2608 "", "a", "123", "en_US", "en-", "-en", "en--US", "toolong", "en-US-INVALID-", ];
2618 for code in invalid_codes {
2619 assert!(
2620 !is_valid_language_code(code),
2621 "Language code '{}' should be invalid",
2622 code
2623 );
2624 }
2625 }
2626
2627 #[test]
2628 fn test_language_code_case_sensitivity() {
2629 assert!(is_valid_language_code("en-GB"));
2630 assert!(is_valid_language_code("fr-FR"));
2631 assert!(is_valid_language_code("zh-Hans"));
2632 assert!(is_valid_language_code("EN-GB"));
2633 }
2634 }
2635
2636 mod aria_role_validation {
2637 use super::*;
2638
2639 #[test]
2640 fn test_valid_button_roles() {
2641 let html = "<button>Test</button>";
2642 let fragment = Html::parse_fragment(html);
2643 let selector = Selector::parse("button").unwrap();
2644 let element =
2645 fragment.select(&selector).next().unwrap();
2646 let valid_roles = ["button", "link", "menuitem"];
2647 for role in valid_roles {
2648 assert!(
2649 is_valid_aria_role(role, &element),
2650 "Role '{}' should be valid for button",
2651 role
2652 );
2653 }
2654 }
2655
2656 #[test]
2657 fn test_valid_input_roles() {
2658 let html = "<input type='text'>";
2659 let fragment = Html::parse_fragment(html);
2660 let selector = Selector::parse("input").unwrap();
2661 let element =
2662 fragment.select(&selector).next().unwrap();
2663 let valid_roles =
2664 ["textbox", "radio", "checkbox", "button"];
2665 for role in valid_roles {
2666 assert!(
2667 is_valid_aria_role(role, &element),
2668 "Role '{}' should be valid for input",
2669 role
2670 );
2671 }
2672 }
2673
2674 #[test]
2675 fn test_valid_anchor_roles() {
2676 let html = "<a href=\"\\#\">Test</a>";
2677 let fragment = Html::parse_fragment(html);
2678 let selector = Selector::parse("a").unwrap();
2679 let element =
2680 fragment.select(&selector).next().unwrap();
2681
2682 let valid_roles = ["button", "link", "menuitem"];
2683 for role in valid_roles {
2684 assert!(
2685 is_valid_aria_role(role, &element),
2686 "Role '{}' should be valid for anchor",
2687 role
2688 );
2689 }
2690 }
2691
2692 #[test]
2693 fn test_invalid_element_roles() {
2694 let html = "<button>Test</button>";
2695 let fragment = Html::parse_fragment(html);
2696 let selector = Selector::parse("button").unwrap();
2697 let element =
2698 fragment.select(&selector).next().unwrap();
2699 let invalid_roles =
2700 ["textbox", "radio", "checkbox", "invalid"];
2701 for role in invalid_roles {
2702 assert!(
2703 !is_valid_aria_role(role, &element),
2704 "Role '{}' should be invalid for button",
2705 role
2706 );
2707 }
2708 }
2709
2710 #[test]
2711 fn test_unrestricted_elements() {
2712 let html_div = "<div>Test</div>";
2714 let fragment_div = Html::parse_fragment(html_div);
2715 let selector_div = Selector::parse("div").unwrap();
2716 let element_div =
2717 fragment_div.select(&selector_div).next().unwrap();
2718
2719 let html_span = "<span>Test</span>";
2721 let fragment_span = Html::parse_fragment(html_span);
2722 let selector_span = Selector::parse("span").unwrap();
2723 let element_span = fragment_span
2724 .select(&selector_span)
2725 .next()
2726 .unwrap();
2727
2728 let roles =
2729 ["button", "textbox", "navigation", "banner"];
2730
2731 for role in roles {
2732 assert!(
2733 is_valid_aria_role(role, &element_div),
2734 "Role '{}' should be allowed for div",
2735 role
2736 );
2737 assert!(
2738 is_valid_aria_role(role, &element_span),
2739 "Role '{}' should be allowed for span",
2740 role
2741 );
2742 }
2743 }
2744
2745 #[test]
2746 fn test_validate_wcag_with_level_aaa() {
2747 let html =
2748 "<h1>Main Title</h1><h3>Skipped Heading</h3>";
2749 let config = AccessibilityConfig {
2750 wcag_level: WcagLevel::AAA,
2751 ..Default::default()
2752 };
2753 let report =
2754 validate_wcag(html, &config, None).unwrap();
2755 assert!(report.issue_count > 0);
2756 assert_eq!(report.wcag_level, WcagLevel::AAA);
2757 }
2758
2759 #[test]
2760 fn test_html_builder_empty() {
2761 let builder = HtmlBuilder::new("");
2762 assert_eq!(builder.build(), "");
2763 }
2764
2765 #[test]
2766 fn test_generate_unique_id_uniqueness() {
2767 let id1 = generate_unique_id();
2768 let id2 = generate_unique_id();
2769 assert_ne!(id1, id2);
2770 }
2771 }
2772
2773 mod required_aria_properties {
2774 use super::*;
2775 use scraper::{Html, Selector};
2776
2777 #[test]
2778 fn test_combobox_required_properties() {
2779 let html = r#"<div role="combobox">Test</div>"#;
2780 let fragment = Html::parse_fragment(html);
2781 let selector = Selector::parse("div").unwrap();
2782 let element =
2783 fragment.select(&selector).next().unwrap();
2784
2785 let missing =
2786 get_missing_required_aria_properties(&element)
2787 .unwrap();
2788 assert!(missing.contains(&"aria-expanded".to_string()));
2789 }
2790
2791 #[test]
2792 fn test_complete_combobox() {
2793 let html = r#"<div role="combobox" aria-expanded="true">Test</div>"#;
2794 let fragment = Html::parse_fragment(html);
2795 let selector = Selector::parse("div").unwrap();
2796 let element =
2797 fragment.select(&selector).next().unwrap();
2798
2799 let missing =
2800 get_missing_required_aria_properties(&element);
2801 assert!(missing.is_none());
2802 }
2803
2804 #[test]
2805 fn test_add_aria_attributes_empty_html() {
2806 let html = "";
2807 let result = add_aria_attributes(html, None);
2808 assert!(result.is_ok());
2809 assert_eq!(result.unwrap(), "");
2810 }
2811
2812 #[test]
2813 fn test_add_aria_attributes_whitespace_html() {
2814 let html = " ";
2815 let result = add_aria_attributes(html, None);
2816 assert!(result.is_ok());
2817 assert_eq!(result.unwrap(), " ");
2818 }
2819
2820 #[test]
2821 fn test_validate_wcag_with_minimal_config() {
2822 let html = r#"<html lang="en"><div>Accessible Content</div></html>"#;
2823 let config = AccessibilityConfig {
2824 wcag_level: WcagLevel::A,
2825 max_heading_jump: 0, min_contrast_ratio: 0.0, auto_fix: false,
2828 };
2829 let report =
2830 validate_wcag(html, &config, None).unwrap();
2831 assert_eq!(report.issue_count, 0);
2832 }
2833
2834 #[test]
2835 fn test_add_partial_aria_attributes_to_button() {
2836 let html =
2837 r#"<button aria-label="Existing">Click</button>"#;
2838 let result = add_aria_attributes(html, None);
2839 assert!(result.is_ok());
2840 let enhanced = result.unwrap();
2841 assert!(enhanced.contains(r#"aria-label="Existing""#));
2842 }
2843
2844 #[test]
2845 fn test_add_aria_to_elements_with_existing_roles() {
2846 let html = r#"<nav aria-label=\"navigation\" role=\"navigation\" role=\"navigation\">Content</nav>"#;
2847 let result = add_aria_attributes(html, None);
2848 assert!(result.is_ok());
2849 assert_eq!(result.unwrap(), html);
2850 }
2851
2852 #[test]
2853 fn test_slider_required_properties() {
2854 let html = r#"<div role="slider">Test</div>"#;
2855 let fragment = Html::parse_fragment(html);
2856 let selector = Selector::parse("div").unwrap();
2857 let element =
2858 fragment.select(&selector).next().unwrap();
2859
2860 let missing =
2861 get_missing_required_aria_properties(&element)
2862 .unwrap();
2863
2864 assert!(missing.contains(&"aria-valuenow".to_string()));
2865 assert!(missing.contains(&"aria-valuemin".to_string()));
2866 assert!(missing.contains(&"aria-valuemax".to_string()));
2867 }
2868
2869 #[test]
2870 fn test_complete_slider() {
2871 let html = r#"<div role="slider"
2872 aria-valuenow="50"
2873 aria-valuemin="0"
2874 aria-valuemax="100">Test</div>"#;
2875 let fragment = Html::parse_fragment(html);
2876 let selector = Selector::parse("div").unwrap();
2877 let element =
2878 fragment.select(&selector).next().unwrap();
2879
2880 let missing =
2881 get_missing_required_aria_properties(&element);
2882 assert!(missing.is_none());
2883 }
2884
2885 #[test]
2886 fn test_partial_slider_properties() {
2887 let html = r#"<div role="slider" aria-valuenow="50">Test</div>"#;
2888 let fragment = Html::parse_fragment(html);
2889 let selector = Selector::parse("div").unwrap();
2890 let element =
2891 fragment.select(&selector).next().unwrap();
2892
2893 let missing =
2894 get_missing_required_aria_properties(&element)
2895 .unwrap();
2896
2897 assert!(!missing.contains(&"aria-valuenow".to_string()));
2898 assert!(missing.contains(&"aria-valuemin".to_string()));
2899 assert!(missing.contains(&"aria-valuemax".to_string()));
2900 }
2901
2902 #[test]
2903 fn test_unknown_role() {
2904 let html = r#"<div role="unknown">Test</div>"#;
2905 let fragment = Html::parse_fragment(html);
2906 let selector = Selector::parse("div").unwrap();
2907 let element =
2908 fragment.select(&selector).next().unwrap();
2909
2910 let missing =
2911 get_missing_required_aria_properties(&element);
2912 assert!(missing.is_none());
2913 }
2914
2915 #[test]
2916 fn test_no_role() {
2917 let html = "<div>Test</div>";
2918 let fragment = Html::parse_fragment(html);
2919 let selector = Selector::parse("div").unwrap();
2920 let element =
2921 fragment.select(&selector).next().unwrap();
2922
2923 let missing =
2924 get_missing_required_aria_properties(&element);
2925 assert!(missing.is_none());
2926 }
2927 }
2928 }
2929
2930 #[cfg(test)]
2931 mod accessibility_tests {
2932 use crate::accessibility::{
2933 get_missing_required_aria_properties, is_valid_aria_role,
2934 is_valid_language_code,
2935 };
2936 use scraper::Selector;
2937
2938 #[test]
2939 fn test_is_valid_language_code() {
2940 assert!(
2941 is_valid_language_code("en"),
2942 "Valid language code 'en' was incorrectly rejected"
2943 );
2944 assert!(
2945 is_valid_language_code("en-US"),
2946 "Valid language code 'en-US' was incorrectly rejected"
2947 );
2948 assert!(
2949 !is_valid_language_code("123"),
2950 "Invalid language code '123' was incorrectly accepted"
2951 );
2952 assert!(!is_valid_language_code("日本語"), "Non-ASCII language code '日本語' was incorrectly accepted");
2953 }
2954
2955 #[test]
2956 fn test_is_valid_aria_role() {
2957 use scraper::Html;
2958
2959 let html = r#"<button></button>"#;
2960 let document = Html::parse_fragment(html);
2961 let element = document
2962 .select(&Selector::parse("button").unwrap())
2963 .next()
2964 .unwrap();
2965
2966 assert!(
2967 is_valid_aria_role("button", &element),
2968 "Valid ARIA role 'button' was incorrectly rejected"
2969 );
2970
2971 assert!(
2972 !is_valid_aria_role("invalid-role", &element),
2973 "Invalid ARIA role 'invalid-role' was incorrectly accepted"
2974 );
2975 }
2976
2977 #[test]
2978 fn test_get_missing_required_aria_properties() {
2979 use scraper::{Html, Selector};
2980
2981 let html = r#"<div role="slider"></div>"#;
2983 let document = Html::parse_fragment(html);
2984 let element = document
2985 .select(&Selector::parse("div").unwrap())
2986 .next()
2987 .unwrap();
2988
2989 let missing_props =
2990 get_missing_required_aria_properties(&element).unwrap();
2991 assert!(
2992 missing_props.contains(&"aria-valuenow".to_string()),
2993 "Did not detect missing 'aria-valuenow' for role 'slider'"
2994 );
2995 assert!(
2996 missing_props.contains(&"aria-valuemin".to_string()),
2997 "Did not detect missing 'aria-valuemin' for role 'slider'"
2998 );
2999 assert!(
3000 missing_props.contains(&"aria-valuemax".to_string()),
3001 "Did not detect missing 'aria-valuemax' for role 'slider'"
3002 );
3003
3004 let html = r#"<div role="slider" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>"#;
3006 let document = Html::parse_fragment(html);
3007 let element = document
3008 .select(&Selector::parse("div").unwrap())
3009 .next()
3010 .unwrap();
3011
3012 let missing_props =
3013 get_missing_required_aria_properties(&element);
3014 assert!(missing_props.is_none(), "Unexpectedly found missing properties for a complete slider");
3015
3016 let html =
3018 r#"<div role="slider" aria-valuenow="50"></div>"#;
3019 let document = Html::parse_fragment(html);
3020 let element = document
3021 .select(&Selector::parse("div").unwrap())
3022 .next()
3023 .unwrap();
3024
3025 let missing_props =
3026 get_missing_required_aria_properties(&element).unwrap();
3027 assert!(
3028 !missing_props.contains(&"aria-valuenow".to_string()),
3029 "Incorrectly flagged 'aria-valuenow' as missing"
3030 );
3031 assert!(
3032 missing_props.contains(&"aria-valuemin".to_string()),
3033 "Did not detect missing 'aria-valuemin' for role 'slider'"
3034 );
3035 assert!(
3036 missing_props.contains(&"aria-valuemax".to_string()),
3037 "Did not detect missing 'aria-valuemax' for role 'slider'"
3038 );
3039 }
3040 }
3041
3042 #[cfg(test)]
3043 mod additional_tests {
3044 use super::*;
3045 use scraper::Html;
3046
3047 #[test]
3048 fn test_validate_empty_html() {
3049 let html = "";
3050 let config = AccessibilityConfig::default();
3051 let report = validate_wcag(html, &config, None).unwrap();
3052 assert_eq!(
3053 report.issue_count, 0,
3054 "Empty HTML should not produce issues"
3055 );
3056 }
3057
3058 #[test]
3059 fn test_validate_only_whitespace_html() {
3060 let html = " ";
3061 let config = AccessibilityConfig::default();
3062 let report = validate_wcag(html, &config, None).unwrap();
3063 assert_eq!(
3064 report.issue_count, 0,
3065 "Whitespace-only HTML should not produce issues"
3066 );
3067 }
3068
3069 #[test]
3070 fn test_validate_language_with_edge_cases() {
3071 let html = "<html lang=\"en-US\"></html>";
3072 let _config = AccessibilityConfig::default();
3073 let mut issues = Vec::new();
3074 let document = Html::parse_document(html);
3075
3076 AccessibilityReport::check_language_attributes(
3077 &document,
3078 &mut issues,
3079 )
3080 .unwrap();
3081 assert_eq!(
3082 issues.len(),
3083 0,
3084 "Valid language declaration should not create issues"
3085 );
3086 }
3087
3088 #[test]
3089 fn test_validate_invalid_language_code() {
3090 let html = "<html lang=\"invalid-lang\"></html>";
3091 let _config = AccessibilityConfig::default();
3092 let mut issues = Vec::new();
3093 let document = Html::parse_document(html);
3094
3095 AccessibilityReport::check_language_attributes(
3096 &document,
3097 &mut issues,
3098 )
3099 .unwrap();
3100 assert!(
3101 issues
3102 .iter()
3103 .any(|i| i.issue_type
3104 == IssueType::LanguageDeclaration),
3105 "Failed to detect invalid language declaration"
3106 );
3107 }
3108
3109 #[test]
3110 fn test_edge_case_for_generate_unique_id() {
3111 let ids: Vec<String> =
3112 (0..100).map(|_| generate_unique_id()).collect();
3113 let unique_ids: HashSet<String> = ids.into_iter().collect();
3114 assert_eq!(
3115 unique_ids.len(),
3116 100,
3117 "Generated IDs are not unique in edge case testing"
3118 );
3119 }
3120
3121 #[test]
3122 fn test_html_with_non_standard_elements() {
3123 let html =
3124 "<custom-element aria-label=\"test\"></custom-element>";
3125 let cleaned_html = remove_invalid_aria_attributes(html);
3126 assert_eq!(cleaned_html, html, "Unexpectedly modified valid custom element with ARIA attributes");
3127 }
3128
3129 #[test]
3130 fn test_add_aria_to_buttons() {
3131 let html = r#"<button>Click me</button>"#;
3132 let builder = HtmlBuilder::new(html);
3133 let result = add_aria_to_buttons(builder).unwrap().build();
3134 assert!(result.contains("aria-label"));
3135 }
3136
3137 #[test]
3138 fn test_add_aria_to_empty_buttons() {
3139 let html = r#"<button></button>"#;
3140 let builder = HtmlBuilder::new(html);
3141 let result = add_aria_to_buttons(builder).unwrap();
3142 assert!(result.build().contains("aria-label"));
3143 }
3144
3145 #[test]
3146 fn test_validate_wcag_empty_html() {
3147 let html = "";
3148 let config = AccessibilityConfig::default();
3149 let disable_checks = None;
3150
3151 let result = validate_wcag(html, &config, disable_checks);
3152
3153 match result {
3154 Ok(report) => assert!(
3155 report.issues.is_empty(),
3156 "Empty HTML should have no issues"
3157 ),
3158 Err(e) => {
3159 panic!("Validation failed with error: {:?}", e)
3160 }
3161 }
3162 }
3163
3164 #[test]
3165 fn test_validate_wcag_with_complex_html() {
3166 let html = "
3167 <html>
3168 <head></head>
3169 <body>
3170 <button>Click me</button>
3171 <a href=\"\\#\"></a>
3172 </body>
3173 </html>
3174 ";
3175 let config = AccessibilityConfig::default();
3176 let disable_checks = None;
3177 let result = validate_wcag(html, &config, disable_checks);
3178
3179 match result {
3180 Ok(report) => assert!(
3181 !report.issues.is_empty(),
3182 "Report should have issues"
3183 ),
3184 Err(e) => {
3185 panic!("Validation failed with error: {:?}", e)
3186 }
3187 }
3188 }
3189
3190 #[test]
3191 fn test_generate_unique_id_uniqueness() {
3192 let id1 = generate_unique_id();
3193 let id2 = generate_unique_id();
3194 assert_ne!(id1, id2);
3195 }
3196
3197 #[test]
3198 fn test_try_create_selector_valid() {
3199 let selector = try_create_selector("div.class");
3202 let html = scraper::Html::parse_fragment(
3203 r#"<div class="class">x</div>"#,
3204 );
3205 assert!(html.select(&selector).next().is_some());
3206 }
3207
3208 #[test]
3209 #[should_panic(expected = "static selector")]
3210 fn test_try_create_selector_invalid_panics() {
3211 let _ = try_create_selector("div..class");
3214 }
3215
3216 #[test]
3217 fn test_try_create_regex_valid() {
3218 let regex = try_create_regex(r"\d+");
3220 assert!(regex.is_match("42"));
3221 }
3222
3223 #[test]
3224 #[should_panic(expected = "static regex")]
3225 fn test_try_create_regex_invalid_panics() {
3226 let _ = try_create_regex(r"\d+(");
3229 }
3230
3231 #[test]
3233 fn test_error_from_try_from_int_error() {
3234 let result: std::result::Result<u8, _> = i32::try_into(300); let err = result.unwrap_err(); let error: Error = Error::from(err);
3238
3239 if let Error::HtmlProcessingError { message, source } =
3240 error
3241 {
3242 assert_eq!(message, "Integer conversion error");
3243 assert!(source.is_some());
3244 } else {
3245 panic!("Expected HtmlProcessingError");
3246 }
3247 }
3248
3249 #[test]
3251 fn test_wcag_level_display() {
3252 assert_eq!(WcagLevel::A.to_string(), "A");
3253 assert_eq!(WcagLevel::AA.to_string(), "AA");
3254 assert_eq!(WcagLevel::AAA.to_string(), "AAA");
3255 }
3256
3257 #[test]
3259 fn test_check_keyboard_navigation() {
3260 let document =
3261 Html::parse_document("<a tabindex='-1'></a>");
3262 let mut issues = vec![];
3263 let result = AccessibilityReport::check_keyboard_navigation(
3264 &document,
3265 &mut issues,
3266 );
3267 assert!(result.is_ok());
3268 assert_eq!(issues.len(), 1);
3269 assert_eq!(
3270 issues[0].message,
3271 "Negative tabindex prevents keyboard focus"
3272 );
3273 }
3274
3275 #[test]
3277 fn test_check_language_attributes() {
3278 let document = Html::parse_document("<html></html>");
3279 let mut issues = vec![];
3280 let result = AccessibilityReport::check_language_attributes(
3281 &document,
3282 &mut issues,
3283 );
3284 assert!(result.is_ok());
3285 assert_eq!(issues.len(), 1);
3286 assert_eq!(
3287 issues[0].message,
3288 "Missing language declaration"
3289 );
3290 }
3291 }
3292
3293 mod missing_tests {
3294 use super::*;
3295 use std::collections::HashSet;
3296
3297 #[test]
3299 fn test_color_contrast_ratio() {
3300 let low_contrast = 2.5;
3301 let high_contrast = 7.1;
3302
3303 let config = AccessibilityConfig {
3304 min_contrast_ratio: 4.5,
3305 ..Default::default()
3306 };
3307
3308 assert!(
3309 low_contrast < config.min_contrast_ratio,
3310 "Low contrast should not pass"
3311 );
3312
3313 assert!(
3314 high_contrast >= config.min_contrast_ratio,
3315 "High contrast should pass"
3316 );
3317 }
3318
3319 #[test]
3321 fn test_dynamic_content_aria_attributes() {
3322 let html = r#"<div aria-live="polite"></div>"#;
3323 let cleaned_html = remove_invalid_aria_attributes(html);
3324 assert_eq!(
3325 cleaned_html, html,
3326 "Dynamic content ARIA attributes should be preserved"
3327 );
3328 }
3329
3330 #[test]
3332 fn test_strict_wcag_aaa_behavior() {
3333 let html = r#"<h1>Main Title</h1><h4>Skipped Level</h4>"#;
3334 let config = AccessibilityConfig {
3335 wcag_level: WcagLevel::AAA,
3336 ..Default::default()
3337 };
3338
3339 let report = validate_wcag(html, &config, None).unwrap();
3340 assert!(
3341 report.issue_count > 0,
3342 "WCAG AAA strictness should detect issues"
3343 );
3344
3345 let issue = &report.issues[0];
3346 assert_eq!(
3347 issue.issue_type,
3348 IssueType::LanguageDeclaration,
3349 "Expected heading structure issue"
3350 );
3351 }
3352
3353 #[test]
3355 fn test_large_html_performance() {
3356 let large_html =
3357 "<div>".repeat(1_000) + &"</div>".repeat(1_000);
3358 let result = validate_wcag(
3359 &large_html,
3360 &AccessibilityConfig::default(),
3361 None,
3362 );
3363 assert!(
3364 result.is_ok(),
3365 "Large HTML should not cause performance issues"
3366 );
3367 }
3368
3369 #[test]
3371 fn test_nested_elements_with_aria_attributes() {
3372 let html = r#"
3373 <div>
3374 <button aria-label="Test">Click</button>
3375 <nav aria-label="Main Navigation">
3376 <ul><li>Item 1</li></ul>
3377 </nav>
3378 </div>
3379 "#;
3380 let enhanced_html =
3381 add_aria_attributes(html, None).unwrap();
3382 assert!(
3383 enhanced_html.contains("aria-label"),
3384 "Nested elements should have ARIA attributes"
3385 );
3386 }
3387
3388 #[test]
3390 fn test_deeply_nested_headings() {
3391 let html = r#"
3392 <div>
3393 <h1>Main Title</h1>
3394 <div>
3395 <h3>Skipped Level</h3>
3396 </div>
3397 </div>
3398 "#;
3399 let mut issues = Vec::new();
3400 let document = Html::parse_document(html);
3401 check_heading_structure(&document, &mut issues);
3402
3403 assert!(
3404 issues.iter().any(|issue| issue.issue_type == IssueType::HeadingStructure),
3405 "Deeply nested headings with skipped levels should produce issues"
3406 );
3407 }
3408
3409 #[test]
3411 fn test_unique_id_long_runtime() {
3412 let ids: HashSet<_> =
3413 (0..10_000).map(|_| generate_unique_id()).collect();
3414 assert_eq!(
3415 ids.len(),
3416 10_000,
3417 "Generated IDs should be unique over long runtime"
3418 );
3419 }
3420
3421 #[test]
3423 #[should_panic(expected = "static selector")]
3424 fn test_custom_selector_failure_panics() {
3425 let _ = try_create_selector("div..class");
3426 }
3427
3428 #[test]
3430 #[should_panic(expected = "static regex")]
3431 fn test_invalid_regex_pattern_panics() {
3432 let _ = try_create_regex(r"\d+(");
3433 }
3434
3435 #[test]
3437 fn test_invalid_aria_attribute_removal() {
3438 let html = r#"<div aria-hidden="invalid"></div>"#;
3439 let cleaned_html = remove_invalid_aria_attributes(html);
3440 assert!(
3441 !cleaned_html.contains("aria-hidden"),
3442 "Invalid ARIA attributes should be removed"
3443 );
3444 }
3445
3446 #[test]
3449 #[should_panic(expected = "static selector")]
3450 fn test_invalid_selector_panics() {
3451 let _ = try_create_selector("div..class");
3452 }
3453
3454 #[test]
3456 fn test_issue_type_in_issue_struct() {
3457 let issue = Issue {
3458 issue_type: IssueType::MissingAltText,
3459 message: "Alt text is missing".to_string(),
3460 guideline: Some("WCAG 1.1.1".to_string()),
3461 element: Some("<img>".to_string()),
3462 suggestion: Some(
3463 "Add descriptive alt text".to_string(),
3464 ),
3465 };
3466 assert_eq!(issue.issue_type, IssueType::MissingAltText);
3467 }
3468
3469 #[test]
3471 fn test_add_aria_to_navs() {
3472 let html = "<nav>Main Navigation</nav>";
3473 let builder = HtmlBuilder::new(html);
3474 let result = add_aria_to_navs(builder).unwrap().build();
3475 assert!(result.contains(r#"aria-label="navigation""#));
3476 assert!(result.contains(r#"role="navigation""#));
3477 }
3478
3479 #[test]
3481 fn test_add_aria_to_forms() {
3482 let html = r#"<form>Form Content</form>"#;
3483 let result =
3484 add_aria_to_forms(HtmlBuilder::new(html)).unwrap();
3485 let content = result.build();
3486
3487 assert!(content.contains(r#"id="form-"#));
3488 assert!(content.contains(r#"aria-labelledby="form-"#));
3489 }
3490
3491 #[test]
3493 fn test_check_keyboard_navigation_click_handlers() {
3494 let html = r#"<button onclick="handleClick()"></button>"#;
3495 let document = Html::parse_document(html);
3496 let mut issues = vec![];
3497
3498 AccessibilityReport::check_keyboard_navigation(
3499 &document,
3500 &mut issues,
3501 )
3502 .unwrap();
3503
3504 assert!(
3505 issues.iter().any(|i| i.message == "Click handler without keyboard equivalent"),
3506 "Expected an issue for missing keyboard equivalents, but found: {:?}",
3507 issues
3508 );
3509 }
3510
3511 #[test]
3513 fn test_invalid_language_code() {
3514 let html = r#"<html lang="invalid-lang"></html>"#;
3515 let document = Html::parse_document(html);
3516 let mut issues = vec![];
3517 AccessibilityReport::check_language_attributes(
3518 &document,
3519 &mut issues,
3520 )
3521 .unwrap();
3522 assert!(issues
3523 .iter()
3524 .any(|i| i.message.contains("Invalid language code")));
3525 }
3526
3527 #[test]
3529 fn test_missing_required_aria_properties() {
3530 let html = r#"<div role="slider"></div>"#;
3531 let fragment = Html::parse_fragment(html);
3532 let element = fragment
3533 .select(&Selector::parse("div").unwrap())
3534 .next()
3535 .unwrap();
3536 let missing =
3537 get_missing_required_aria_properties(&element).unwrap();
3538 assert!(missing.contains(&"aria-valuenow".to_string()));
3539 }
3540
3541 #[test]
3543 #[should_panic(expected = "static regex")]
3544 fn test_invalid_regex_creation_panics() {
3545 let _ = try_create_regex("[unclosed");
3546 }
3547
3548 #[test]
3550 #[should_panic(expected = "static selector")]
3551 fn test_invalid_selector_creation_panics() {
3552 let _ = try_create_selector("div..class");
3553 }
3554
3555 #[test]
3557 fn test_add_aria_empty_buttons() {
3558 let html = r#"<button></button>"#;
3559 let builder = HtmlBuilder::new(html);
3560 let result = add_aria_to_buttons(builder).unwrap().build();
3561 assert!(
3562 result.contains("aria-label"),
3563 "ARIA label should be added to empty button"
3564 );
3565 }
3566
3567 #[test]
3569 fn test_wcag_aaa_validation() {
3570 let html = "<h1>Main Title</h1><h4>Skipped Heading</h4>";
3571 let config = AccessibilityConfig {
3572 wcag_level: WcagLevel::AAA,
3573 ..Default::default()
3574 };
3575 let report = validate_wcag(html, &config, None).unwrap();
3576 assert!(
3577 report.issue_count > 0,
3578 "WCAG AAA should detect issues"
3579 );
3580 }
3581
3582 #[test]
3584 fn test_unique_id_collisions() {
3585 let ids: HashSet<_> =
3586 (0..10_000).map(|_| generate_unique_id()).collect();
3587 assert_eq!(
3588 ids.len(),
3589 10_000,
3590 "Generated IDs should be unique"
3591 );
3592 }
3593
3594 #[test]
3596 fn test_add_aria_navigation() {
3597 let html = "<nav>Main Navigation</nav>";
3598 let builder = HtmlBuilder::new(html);
3599 let result = add_aria_to_navs(builder).unwrap().build();
3600 assert!(
3601 result.contains("aria-label"),
3602 "ARIA label should be added to navigation"
3603 );
3604 }
3605
3606 #[test]
3608 fn test_empty_html_handling() {
3609 let html = "";
3610 let result = add_aria_attributes(html, None);
3611 assert!(
3612 result.is_ok(),
3613 "Empty HTML should not cause errors"
3614 );
3615 assert_eq!(
3616 result.unwrap(),
3617 "",
3618 "Empty HTML should remain unchanged"
3619 );
3620 }
3621
3622 #[test]
3623 fn test_add_aria_to_inputs_with_different_types() {
3624 let html = r#"
3625 <input type="text" placeholder="Username">
3626 <input type="password" placeholder="Password">
3627 <input type="checkbox" id="remember">
3628 <input type="radio" name="choice">
3629 <input type="submit" value="Submit">
3630 <input type="unknown">
3631 "#;
3632
3633 let builder = HtmlBuilder::new(html);
3634 let result = add_aria_to_inputs(builder).unwrap().build();
3635
3636 assert!(!result.contains(r#"type="text".*aria-label"#));
3638 assert!(!result.contains(r#"type="password".*aria-label"#));
3639
3640 assert!(result.contains(
3642 r#"<label for="remember">Checkbox for remember</label>"#
3643 ));
3644
3645 assert!(result
3647 .contains(r#"<label for="option1">Option 1</label>"#));
3648
3649 assert!(!result.contains(r#"type="submit".*aria-label"#));
3651
3652 assert!(result.contains(r#"aria-label="unknown""#));
3654 }
3655
3656 #[test]
3657 fn test_has_associated_label() {
3658 let input = r#"<input type="text" id="username">"#;
3660 let html = r#"<label for="username">Username:</label>"#;
3661 assert!(has_associated_label(input, html));
3662
3663 let input = r#"<input type="text" id="username">"#;
3665 let html = r#"<label for="password">Password:</label>"#;
3666 assert!(!has_associated_label(input, html));
3667
3668 let input = r#"<input type="text">"#;
3670 let html = r#"<label for="username">Username:</label>"#;
3671 assert!(!has_associated_label(input, html));
3672 }
3673
3674 #[test]
3675 fn test_preserve_attributes() {
3676 let input = r#"<input type="text" class="form-control">"#;
3678 let result = preserve_attributes(input);
3679 assert!(result.contains("type=\"text\""));
3680 assert!(result.contains("class=\"form-control\""));
3681
3682 let input = r#"<input type="text">"#;
3684 let result = preserve_attributes(input);
3685 assert!(result.contains("type=\"text\""));
3686
3687 let input = r#"<input type='text'>"#;
3689 let result = preserve_attributes(input);
3690 assert!(result.contains("type='text'"));
3691
3692 let input = r#"<input required>"#;
3694 let result = preserve_attributes(input);
3695 assert!(result.contains("required"));
3696
3697 let input = "<input>";
3699 let result = preserve_attributes(input);
3700 assert!(
3701 result.contains("input"),
3702 "Should preserve the input tag name"
3703 );
3704
3705 let input = r#"<input name="test" value="multiple words">"#;
3707 let result = preserve_attributes(input);
3708 assert!(result.contains("name=\"test\""));
3709 assert!(result.contains("value=\"multiple words\""));
3710 }
3711
3712 #[test]
3713 fn test_preserve_attributes_with_data_attributes() {
3714 let input = r#"<input data-test="value" type="text">"#;
3716 let matches: Vec<String> = ATTRIBUTE_REGEX
3717 .captures_iter(input)
3718 .map(|cap| cap[0].to_string())
3719 .collect();
3720 println!("Actual matches: {:?}", matches);
3721
3722 let result = preserve_attributes(input);
3723 println!("Preserved attributes: {}", result);
3724 }
3725
3726 #[test]
3727 fn test_extract_input_type() {
3728 let input = r#"<input type="text" class="form-control">"#;
3730 assert_eq!(
3731 extract_input_type(input),
3732 Some("text".to_string())
3733 );
3734
3735 let input = r#"<input type='radio' name='choice'>"#;
3737 assert_eq!(
3738 extract_input_type(input),
3739 Some("radio".to_string())
3740 );
3741
3742 let input = r#"<input class="form-control">"#;
3744 assert_eq!(extract_input_type(input), None);
3745
3746 let input = r#"<input type="" class="form-control">"#;
3748 assert_eq!(extract_input_type(input), None); }
3750
3751 #[test]
3752 fn test_add_aria_to_inputs_with_existing_labels() {
3753 let html = r#"
3754 <input type="checkbox" id="existing">
3755 <label for="existing">Existing Label</label>
3756 <input type="radio" id="existing2">
3757 <label for="existing2">Existing Radio</label>
3758 "#;
3759
3760 let builder = HtmlBuilder::new(html);
3761 let result = add_aria_to_inputs(builder).unwrap().build();
3762
3763 assert!(!result.contains("aria-label"));
3765 assert_eq!(
3766 result.matches("<label").count(),
3767 2,
3768 "Should not add additional labels to elements that already have them"
3769 );
3770 }
3771
3772 #[test]
3773 fn test_add_aria_to_inputs_with_special_characters() {
3774 let html = r#"<input type="text" data-test="test's value" class="form & input">"#;
3775 let builder = HtmlBuilder::new(html);
3776 let result = add_aria_to_inputs(builder).unwrap().build();
3777
3778 assert!(result.contains("data-test=\"test's value\""));
3780 assert!(result.contains("class=\"form & input\""));
3781 }
3782
3783 #[test]
3784 fn test_toggle_button() {
3785 let original_html =
3786 r#"<button type="button">Menu</button>"#;
3787 let builder = HtmlBuilder::new(original_html);
3788 let enhanced_html =
3789 add_aria_to_buttons(builder).unwrap().build();
3790
3791 assert_eq!(
3793 enhanced_html,
3794 r#"<button aria-label="menu" type="button">Menu</button>"#,
3795 "The button should be enhanced with an aria-label"
3796 );
3797 }
3798
3799 #[test]
3800 fn test_replace_element_fallback() {
3801 let original = r#"<button disabled>Click</button>"#;
3802 let old_element = r#"<button disabled="">Click</button>"#;
3803 let new_element = r#"<button aria-disabled="true" disabled="">Click</button>"#;
3804
3805 let replaced =
3806 replace_element(original, old_element, new_element);
3807
3808 assert!(replaced.contains(r#"aria-disabled="true""#), "Should replace with fallback even though original has disabled not disabled=\"\"");
3810 }
3811
3812 #[test]
3813 fn test_replace_element_no_match() {
3814 let original = r#"<div>Nothing to replace</div>"#;
3815 let old_element = r#"<button disabled="">Click</button>"#;
3816 let new_element = r#"<button aria-disabled="true" disabled="">Click</button>"#;
3817
3818 let replaced =
3820 replace_element(original, old_element, new_element);
3821 assert_eq!(
3822 replaced, original,
3823 "No match means original stays unchanged"
3824 );
3825 }
3826
3827 #[test]
3828 fn test_normalize_shorthand_attributes_multiple() {
3829 let html = r#"<input disabled selected><button disabled>Press</button>"#;
3830 let normalized = normalize_shorthand_attributes(html);
3831 assert!(
3834 normalized
3835 .contains(r#"<input disabled="" selected="">"#),
3836 "Should expand both disabled and selected"
3837 );
3838 assert!(
3839 normalized.contains(r#"<button disabled="">"#),
3840 "Should expand the disabled attribute on the button"
3841 );
3842 }
3843
3844 #[test]
3845 fn test_remove_invalid_aria_attributes() {
3846 let html = r#"<div aria-hidden="invalid" aria-pressed="true"></div>"#;
3847 let cleaned = remove_invalid_aria_attributes(html);
3850 assert!(
3851 !cleaned.contains(r#"aria-hidden="invalid""#),
3852 "Invalid aria-hidden should be removed"
3853 );
3854 assert!(
3855 cleaned.contains(r#"aria-pressed="true""#),
3856 "Valid attribute should remain"
3857 );
3858 }
3859
3860 #[test]
3861 fn test_is_valid_aria_attribute_cases() {
3862 assert!(
3864 is_valid_aria_attribute("aria-label", "Submit"),
3865 "aria-label with non-empty string is valid"
3866 );
3867
3868 assert!(
3870 is_valid_aria_attribute("aria-pressed", "true"),
3871 "aria-pressed=\"true\" is valid"
3872 );
3873 assert!(
3874 is_valid_aria_attribute("aria-pressed", "false"),
3875 "aria-pressed=\"false\" is valid"
3876 );
3877 assert!(
3878 !is_valid_aria_attribute("aria-pressed", "yes"),
3879 "aria-pressed only allows true/false"
3880 );
3881
3882 assert!(
3884 !is_valid_aria_attribute(
3885 "aria-somethingrandom",
3886 "value"
3887 ),
3888 "Unknown ARIA attribute is invalid"
3889 );
3890 }
3891
3892 #[test]
3893 fn test_add_aria_to_accordions_basic() {
3894 let html = r#"
3895 <div class="accordion">
3896 <button>Section 1</button>
3897 <div>Content 1</div>
3898 <button>Section 2</button>
3899 <div>Content 2</div>
3900 </div>
3901 "#;
3902 let builder = HtmlBuilder::new(html);
3903 let result =
3904 add_aria_to_accordions(builder).unwrap().build();
3905
3906 assert!(
3908 result.contains(r#"aria-controls="section-1-content""#),
3909 "First accordion section should have aria-controls"
3910 );
3911 assert!(
3912 result.contains(r#"id="section-1-button""#),
3913 "First button should get an ID"
3914 );
3915 assert!(
3916 result.contains(r#"id="section-1-content""#),
3917 "First content should get an ID"
3918 );
3919 assert!(
3920 result.contains(r#"hidden"#),
3921 "Accordion content is hidden by default"
3922 );
3923 }
3924
3925 #[test]
3926 fn test_add_aria_to_accordions_empty() {
3927 let html = r#"<div class="accordion"></div>"#;
3928 let builder = HtmlBuilder::new(html);
3929 let result =
3930 add_aria_to_accordions(builder).unwrap().build();
3931
3932 assert!(result.contains(r#"class="accordion""#));
3934 }
3936
3937 #[test]
3938 fn test_add_aria_to_tabs_basic() {
3939 let html = r#"
3941 <div role="tablist">
3942 <button>Tab A</button>
3943 <button>Tab B</button>
3944 </div>
3945 "#;
3946 let builder = HtmlBuilder::new(html);
3947 let result = add_aria_to_tabs(builder).unwrap().build();
3948
3949 assert!(
3951 result.contains(
3952 r#"role="tab" id="tab1" aria-selected="true""#
3953 ),
3954 "First tab should be tab1, selected=true"
3955 );
3956 assert!(
3957 result.contains(
3958 r#"role="tab" id="tab2" aria-selected="false""#
3959 ),
3960 "Second tab should be tab2, selected=false"
3961 );
3962 assert!(
3964 result.contains(r#"aria-controls="panel1""#),
3965 "First tab controls panel1"
3966 );
3967 assert!(
3968 result.contains(r#"id="panel2" role="tabpanel""#),
3969 "Second tab panel should exist"
3970 );
3971 }
3972
3973 #[test]
3975 fn test_add_aria_to_tabs_no_tablist() {
3976 let html = r#"<div><button>Not a tab</button></div>"#;
3977 let builder = HtmlBuilder::new(html);
3978 let result = add_aria_to_tabs(builder).unwrap().build();
3979
3980 assert!(
3982 result.contains(r#"<button>Not a tab</button>"#),
3983 "Should remain unchanged"
3984 );
3985 assert!(!result.contains(r#"role="tab""#), "No transformation to role=tab if not inside role=tablist");
3986 }
3987
3988 #[test]
3990 fn test_count_checked_elements() {
3991 let html = r#"
3992 <html>
3993 <body>
3994 <div>
3995 <p>Paragraph</p>
3996 <span>Span</span>
3997 </div>
3998 </body>
3999 </html>
4000 "#;
4001 let document = Html::parse_document(html);
4002 let count = count_checked_elements(&document);
4003 assert!(
4007 count >= 5,
4008 "Expected at least 5 elements in the parsed tree"
4009 );
4010 }
4011
4012 #[test]
4013 fn test_check_language_attributes_valid() {
4014 let html = r#"<html lang="en"><body></body></html>"#;
4015 let document = Html::parse_document(html);
4016 let mut issues = vec![];
4017 let result = AccessibilityReport::check_language_attributes(
4018 &document,
4019 &mut issues,
4020 );
4021 assert!(result.is_ok());
4022 assert_eq!(issues.len(), 0, "No issues for valid lang");
4023 }
4024
4025 #[test]
4026 fn test_error_variants() {
4027 let _ = Error::InvalidAriaAttribute {
4028 attribute: "aria-bogus".to_string(),
4029 message: "Bogus attribute".to_string(),
4030 };
4031 let _ = Error::WcagValidationError {
4032 level: WcagLevel::AA,
4033 message: "Validation failed".to_string(),
4034 guideline: Some("WCAG 2.4.6".to_string()),
4035 };
4036 let _ = Error::HtmlTooLarge {
4037 size: 9999999,
4038 max_size: 1000000,
4039 };
4040 let _ = Error::HtmlProcessingError {
4041 message: "Something went wrong".to_string(),
4042 source: None,
4043 };
4044 let _ = Error::MalformedHtml {
4045 message: "Broken HTML".to_string(),
4046 fragment: None,
4047 };
4048 }
4049
4050 #[test]
4051 fn test_has_associated_label_no_id() {
4052 let input = r#"<input type="checkbox">"#;
4053 let html =
4054 r#"<label for="checkbox1">Checkbox Label</label>"#;
4055 assert!(
4057 !has_associated_label(input, html),
4058 "No ID => false"
4059 );
4060 }
4061
4062 #[test]
4063 fn test_generate_unique_id_format() {
4064 let new_id = generate_unique_id();
4065 assert!(
4067 new_id.starts_with("aria-"),
4068 "Generated ID should start with aria-"
4069 );
4070 }
4071
4072 #[test]
4073 fn test_add_aria_to_buttons_basic_button() {
4074 let html = r#"<button>Click me</button>"#;
4075 let builder = HtmlBuilder::new(html);
4076 let result = add_aria_to_buttons(builder).unwrap().build();
4077
4078 assert!(
4081 result.contains(r#"aria-label="click-me""#),
4082 "Should add aria-label for normal button text"
4083 );
4084 assert!(
4085 !result.contains(r#"aria-pressed=""#),
4086 "Should not add aria-pressed if not originally present"
4087 );
4088 }
4089
4090 #[test]
4091 fn test_add_aria_to_buttons_disabled() {
4092 let html = r#"<button disabled>Submit</button>"#;
4093 let builder = HtmlBuilder::new(html);
4094 let result = add_aria_to_buttons(builder).unwrap().build();
4095 assert!(
4098 result.contains(r#"aria-disabled="true""#),
4099 "Disabled button should have aria-disabled"
4100 );
4101 assert!(
4102 result.contains(r#"aria-label="submit""#),
4103 "Should have aria-label from button text"
4104 );
4105 assert!(
4107 !result.contains("aria-pressed"),
4108 "Disabled button shouldn't have aria-pressed"
4109 );
4110 }
4111
4112 #[test]
4113 fn test_add_aria_to_buttons_icon_span() {
4114 let html = r#"<button><span class="icon">🔍</span>Search</button>"#;
4115 let builder = HtmlBuilder::new(html);
4116 let result = add_aria_to_buttons(builder).unwrap().build();
4117
4118 assert!(
4121 result.contains(r#"left-pointing-magnifying-glass""#)
4122 );
4123 assert!(
4124 result.contains(
4125 r#"<span class="icon" aria-hidden="true">🔍</span>"#
4126 ),
4127 "Icon span should have aria-hidden=\"true\""
4128 );
4129 }
4130
4131 #[test]
4132 fn test_add_aria_to_buttons_toggle_flip() {
4133 let html = r#"<button aria-pressed="true">Toggle</button>"#;
4135 let builder = HtmlBuilder::new(html);
4136 let result = add_aria_to_buttons(builder).unwrap().build();
4137
4138 assert!(
4140 result.contains(r#"aria-pressed="false""#),
4141 "Existing aria-pressed=\"true\" should flip to false"
4142 );
4143 assert!(result.contains(r#"aria-label="toggle""#));
4145 }
4146
4147 #[test]
4148 fn test_add_aria_to_buttons_toggle_no_flip() {
4149 let html = r#"<button aria-pressed="true">On</button>"#;
4153 let builder = HtmlBuilder::new(html);
4158 let result = add_aria_to_buttons(builder).unwrap().build();
4159 assert!(result.contains(r#"aria-pressed="false""#));
4164 }
4165
4166 #[test]
4170 fn test_add_aria_to_toggle_no_aria_pressed() {
4171 let html = r#"<div class="toggle-button">Click me</div>"#;
4173 let builder = HtmlBuilder::new(html);
4174 let result = add_aria_to_toggle(builder).unwrap().build();
4175
4176 let doc = Html::parse_document(&result);
4178 let selector =
4179 Selector::parse("button.toggle-button").unwrap();
4180 let toggle = doc
4181 .select(&selector)
4182 .next()
4183 .expect("Should have button.toggle-button");
4184 assert_eq!(
4185 toggle.value().attr("aria-pressed"),
4186 Some("false")
4187 );
4188 assert_eq!(toggle.value().attr("role"), Some("button"));
4189 assert_eq!(toggle.inner_html().trim(), "Click me");
4190 }
4191
4192 #[test]
4193 fn test_add_aria_to_toggle_existing_aria_pressed() {
4194 let html = r#"<div class="toggle-button" aria-pressed="true">I'm on</div>"#;
4196 let builder = HtmlBuilder::new(html);
4197 let result = add_aria_to_toggle(builder).unwrap().build();
4198
4199 assert!(
4201 result.contains("toggle-button"),
4202 "Should preserve the toggle-button class"
4203 );
4204 assert!(
4205 result.contains("I'm on"),
4206 "Should preserve the content"
4207 );
4208 assert!(
4209 result.contains(r#"aria-pressed="true""#),
4210 "Should preserve aria-pressed value"
4211 );
4212 }
4213
4214 #[test]
4215 fn test_add_aria_to_toggle_preserves_other_attrs() {
4216 let html = r#"<div class="toggle-button" data-role="switch" style="color:red;" aria-pressed="false">Toggle</div>"#;
4217 let builder = HtmlBuilder::new(html);
4218 let result = add_aria_to_toggle(builder).unwrap().build();
4219
4220 assert!(
4222 result.contains(r#"class="toggle-button""#),
4223 "Should preserve class"
4224 );
4225 assert!(
4226 result.contains(r#"data-role="switch""#),
4227 "Should preserve data attribute"
4228 );
4229 assert!(
4230 result.contains(r#"style="color:red;""#),
4231 "Should preserve style"
4232 );
4233 assert!(
4234 result.contains(r#"aria-pressed="false""#),
4235 "Should preserve aria-pressed"
4236 );
4237 }
4238
4239 #[test]
4240 fn test_add_aria_to_toggle_no_toggle_elements() {
4241 let html = r#"<div>Just a regular div</div>"#;
4242 let builder = HtmlBuilder::new(html);
4243 let result = add_aria_to_toggle(builder).unwrap().build();
4244 assert_eq!(
4246 result, html,
4247 "No transformation if there's no .toggle-button"
4248 );
4249 }
4250
4251 #[test]
4252 fn test_has_alert_class_sets_alertdialog() -> Result<()> {
4253 let original_html = r#"
4255 <div class="modal alert">
4256 <div class="modal-content"><h2>Warning</h2><button>OK</button></div>
4257 </div>
4258 "#;
4259 let builder = HtmlBuilder {
4260 content: original_html.to_string(),
4261 };
4262
4263 let result = add_aria_to_modals(builder)?;
4264 let output = result.content;
4265
4266 assert!(
4268 output.contains(r#"role="alertdialog""#),
4269 "Expected role=\"alertdialog\" for .alert class"
4270 );
4271 assert!(
4272 output.contains(r#"aria-modal="true""#),
4273 "Expected aria-modal=\"true\" to be set"
4274 );
4275 Ok(())
4276 }
4277
4278 #[test]
4279 fn test_preserves_role_dialog() -> Result<()> {
4280 let original_html = r#"
4282 <div class="modal" role="dialog">
4283 <div class="modal-content"><button>Close</button></div>
4284 </div>
4285 "#;
4286 let builder = HtmlBuilder {
4287 content: original_html.to_string(),
4288 };
4289
4290 let result = add_aria_to_modals(builder)?;
4291 let output = result.content;
4292
4293 assert!(
4295 output.contains(r#"role="dialog""#),
4296 "Should preserve role=\"dialog\""
4297 );
4298 assert!(
4300 output.contains(r#"aria-modal="true""#),
4301 "Expected aria-modal=\"true\" to be added"
4302 );
4303 Ok(())
4304 }
4305
4306 #[test]
4307 fn test_preserves_role_alertdialog() -> Result<()> {
4308 let original_html = r#"
4310 <div class="modal" role="alertdialog">
4311 <div class="modal-content"><h2>Warning</h2></div>
4312 </div>
4313 "#;
4314 let builder = HtmlBuilder {
4315 content: original_html.to_string(),
4316 };
4317
4318 let result = add_aria_to_modals(builder)?;
4319 let output = result.content;
4320
4321 assert!(
4323 output.contains(r#"role="alertdialog""#),
4324 "Should preserve role=\"alertdialog\""
4325 );
4326 assert!(
4328 output.contains(r#"aria-modal="true""#),
4329 "Expected aria-modal=\"true\" to be added"
4330 );
4331 Ok(())
4332 }
4333
4334 #[test]
4335 fn test_already_has_aria_modal_does_not_duplicate() -> Result<()>
4336 {
4337 let original_html = r#"
4339 <div class="modal" role="dialog" aria-modal="true">
4340 <div class="modal-content"><button>Close</button></div>
4341 </div>
4342 "#;
4343 let builder = HtmlBuilder {
4344 content: original_html.to_string(),
4345 };
4346
4347 let result = add_aria_to_modals(builder)?;
4348 let output = result.content;
4349
4350 let count = output.matches(r#"aria-modal="true""#).count();
4353 assert_eq!(
4354 count, 1,
4355 "aria-modal=\"true\" should only appear once"
4356 );
4357 Ok(())
4358 }
4359
4360 #[test]
4361 fn test_adds_aria_describedby_for_dialog_description(
4362 ) -> Result<()> {
4363 let original_html = r#"
4365 <div class="modal">
4366 <div class="dialog-description">This is an important message</div>
4367 <div class="modal-content"><button>Close</button></div>
4368 </div>
4369 "#;
4370 let builder = HtmlBuilder {
4371 content: original_html.to_string(),
4372 };
4373
4374 let result = add_aria_to_modals(builder)?;
4375 let output = result.content;
4376
4377 assert!(
4379 output.contains(r#"role="dialog""#),
4380 "Expected role=\"dialog\""
4381 );
4382 assert!(
4383 output.contains(r#"aria-modal="true""#),
4384 "Expected aria-modal=\"true\""
4385 );
4386
4387 let has_aria_describedby =
4390 output.contains("aria-describedby=");
4391 assert!(
4392 has_aria_describedby,
4393 "Should have aria-describedby referencing the .dialog-description"
4394 );
4395 Ok(())
4396 }
4397
4398 #[test]
4399 fn test_dialog_description_missing_does_not_add_aria_describedby(
4400 ) -> Result<()> {
4401 let original_html = r#"
4403 <div class="modal">
4404 <div class="modal-content"><button>Close</button></div>
4405 </div>
4406 "#;
4407
4408 let builder = HtmlBuilder {
4409 content: original_html.to_string(),
4410 };
4411
4412 let result = add_aria_to_modals(builder)?;
4413 let output = result.content;
4414
4415 assert!(
4417 !output.contains("aria-describedby="),
4418 "Should not add aria-describedby if no descriptive element is found"
4419 );
4420 Ok(())
4421 }
4422
4423 #[test]
4424 fn test_paragraph_as_dialog_description() -> Result<()> {
4425 let original_html = r#"
4427 <div class="modal">
4428 <p>This is a brief description of the dialog.</p>
4429 <div class="modal-content"><button>Close</button></div>
4430 </div>
4431 "#;
4432
4433 let builder = HtmlBuilder {
4434 content: original_html.to_string(),
4435 };
4436
4437 let result = add_aria_to_modals(builder)?;
4438 let output = result.content;
4439
4440 assert!(
4444 output.contains("aria-describedby="),
4445 "Should have aria-describedby referencing the <p>"
4446 );
4447 assert!(
4448 output.contains("id=\"dialog-desc-"),
4449 "Should have auto-generated ID assigned to <p>"
4450 );
4451 Ok(())
4452 }
4453 }
4454}