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 .is_none_or(|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
1838static LABEL_FOR_REGEX: Lazy<Regex> = Lazy::new(|| {
1839 Regex::new(r#"<label\b[^>]*\bfor=["']([^"']+)["'][^>]*>"#)
1840 .expect("static LABEL_FOR_REGEX must compile")
1841});
1842
1843fn has_associated_label(input_tag: &str, html_content: &str) -> bool {
1845 if let Some(id_match) = INPUT_ID_REGEX.captures(input_tag) {
1846 let id = &id_match[1];
1847 LABEL_FOR_REGEX
1848 .captures_iter(html_content)
1849 .any(|cap| cap.get(1).is_some_and(|m| m.as_str() == id))
1850 } else {
1851 false
1852 }
1853}
1854
1855static ATTRIBUTE_REGEX: Lazy<Regex> = Lazy::new(|| {
1857 try_create_regex(
1858 r#"(?:data-\w+|[a-zA-Z]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\S+))?"#,
1859 )
1860});
1861
1862fn preserve_attributes(input_tag: &str) -> String {
1864 ATTRIBUTE_REGEX
1865 .captures_iter(input_tag)
1866 .map(|cap| cap[0].to_string())
1867 .collect::<Vec<String>>()
1868 .join(" ")
1869}
1870
1871fn extract_input_type(input_tag: &str) -> Option<String> {
1873 static TYPE_REGEX: Lazy<Regex> = Lazy::new(|| {
1874 Regex::new(r#"type=["']([^"']+)["']"#)
1875 .expect("static TYPE_REGEX must compile")
1876 });
1877
1878 TYPE_REGEX
1879 .captures(input_tag)
1880 .and_then(|cap| cap.get(1))
1881 .map(|m| m.as_str().to_string())
1882}
1883
1884#[cfg(test)]
1892fn generate_unique_id() -> String {
1893 use std::sync::atomic::{AtomicU64, Ordering};
1894 static COUNTER: AtomicU64 = AtomicU64::new(0);
1895 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1896 format!("aria-{n}")
1897}
1898
1899fn remove_invalid_aria_attributes(html: &str) -> String {
1900 let document = Html::parse_fragment(html);
1904 let mut replacer: Option<DomReplacer> = None;
1905
1906 for element in document.select(&ARIA_SELECTOR) {
1907 let has_invalid = element.value().attrs().any(|(n, v)| {
1909 n.starts_with("aria-") && !is_valid_aria_attribute(n, v)
1910 });
1911 if !has_invalid {
1912 continue;
1913 }
1914
1915 let element_html = element.html();
1916 let mut updated_html = element_html.clone();
1917 for (attr_name, attr_value) in element.value().attrs() {
1918 if attr_name.starts_with("aria-")
1919 && !is_valid_aria_attribute(attr_name, attr_value)
1920 {
1921 updated_html = updated_html.replace(
1922 &format!(r#" {}="{}""#, attr_name, attr_value),
1923 "",
1924 );
1925 }
1926 }
1927
1928 replacer
1929 .get_or_insert_with(|| DomReplacer::new(html))
1930 .push(&element_html, &updated_html);
1931 }
1932
1933 match replacer {
1936 Some(r) => r.apply(),
1937 None => html.to_string(),
1938 }
1939}
1940
1941fn is_valid_aria_attribute(name: &str, value: &str) -> bool {
1943 if !VALID_ARIA_ATTRIBUTES.contains(name) {
1944 return false; }
1946
1947 match name {
1948 "aria-hidden" | "aria-expanded" | "aria-pressed"
1949 | "aria-invalid" => {
1950 matches!(value, "true" | "false") }
1952 _ => !value.trim().is_empty(), }
1959}
1960
1961impl AccessibilityReport {
1963 pub fn check_keyboard_navigation(
1990 document: &Html,
1991 issues: &mut Vec<Issue>,
1992 ) -> Result<()> {
1993 let interactive_elements =
1994 document.select(&INTERACTIVE_SELECTOR);
1995
1996 for element in interactive_elements {
1997 if let Some(tabindex) = element.value().attr("tabindex") {
1999 if let Ok(index) = tabindex.parse::<i32>() {
2000 if index < 0 {
2001 issues.push(Issue {
2002 issue_type: IssueType::KeyboardNavigation,
2003 message: "Negative tabindex prevents keyboard focus".to_string(),
2004 guideline: Some("WCAG 2.1.1".to_string()),
2005 element: Some(element.html()),
2006 suggestion: Some("Remove negative tabindex value".to_string()),
2007 });
2008 }
2009 }
2010 }
2011
2012 if element.value().attr("onclick").is_some()
2014 && element.value().attr("onkeypress").is_none()
2015 && element.value().attr("onkeydown").is_none()
2016 {
2017 issues.push(Issue {
2018 issue_type: IssueType::KeyboardNavigation,
2019 message:
2020 "Click handler without keyboard equivalent"
2021 .to_string(),
2022 guideline: Some("WCAG 2.1.1".to_string()),
2023 element: Some(element.html()),
2024 suggestion: Some(
2025 "Add keyboard event handlers".to_string(),
2026 ),
2027 });
2028 }
2029 }
2030 Ok(())
2031 }
2032
2033 pub fn check_language_attributes(
2056 document: &Html,
2057 issues: &mut Vec<Issue>,
2058 ) -> Result<()> {
2059 let html_element = document.select(&HTML_SELECTOR).next();
2061 if let Some(element) = html_element {
2062 if element.value().attr("lang").is_none() {
2063 Self::add_issue(
2064 issues,
2065 IssueType::LanguageDeclaration,
2066 "Missing language declaration",
2067 Some("WCAG 3.1.1".to_string()),
2068 Some(element.html()),
2069 Some(
2070 "Add lang attribute to html element"
2071 .to_string(),
2072 ),
2073 );
2074 }
2075 }
2076
2077 let text_elements = document.select(&LANG_SELECTOR);
2079 for element in text_elements {
2080 if let Some(lang) = element.value().attr("lang") {
2081 if !is_valid_language_code(lang) {
2082 Self::add_issue(
2083 issues,
2084 IssueType::LanguageDeclaration,
2085 format!("Invalid language code: {}", lang),
2086 Some("WCAG 3.1.2".to_string()),
2087 Some(element.html()),
2088 Some(
2089 "Use valid BCP 47 language code"
2090 .to_string(),
2091 ),
2092 );
2093 }
2094 }
2095 }
2096 Ok(())
2097 }
2098
2099 pub fn check_advanced_aria(
2132 document: &Html,
2133 issues: &mut Vec<Issue>,
2134 ) -> Result<()> {
2135 for element in document.select(&ROLE_SELECTOR) {
2137 if let Some(role) = element.value().attr("role") {
2138 if !is_valid_aria_role(role, &element) {
2139 Self::add_issue(
2140 issues,
2141 IssueType::InvalidAria,
2142 format!(
2143 "Invalid ARIA role '{}' for element",
2144 role
2145 ),
2146 Some("WCAG 4.1.2".to_string()),
2147 Some(element.html()),
2148 Some("Use appropriate ARIA role".to_string()),
2149 );
2150 }
2151 }
2152 }
2153
2154 for element in document.select(&ARIA_SELECTOR) {
2156 if let Some(missing_props) =
2157 get_missing_required_aria_properties(&element)
2158 {
2159 Self::add_issue(
2160 issues,
2161 IssueType::InvalidAria,
2162 format!(
2163 "Missing required ARIA properties: {}",
2164 missing_props.join(", ")
2165 ),
2166 Some("WCAG 4.1.2".to_string()),
2167 Some(element.html()),
2168 Some("Add required ARIA properties".to_string()),
2169 );
2170 }
2171 }
2172 Ok(())
2173 }
2174}
2175
2176pub mod utils {
2189 use scraper::ElementRef;
2190 use std::collections::HashMap;
2191
2192 use once_cell::sync::Lazy;
2194 use regex::Regex;
2195
2196 pub(crate) fn is_valid_language_code(lang: &str) -> bool {
2198 static LANGUAGE_CODE_REGEX: Lazy<Regex> = Lazy::new(|| {
2199 Regex::new(r"(?i)^[a-z]{2,3}(-[a-z0-9]{2,8})*$")
2201 .expect("static LANGUAGE_CODE_REGEX must compile")
2202 });
2203
2204 LANGUAGE_CODE_REGEX.is_match(lang) && !lang.ends_with('-')
2206 }
2207
2208 pub(crate) fn is_valid_aria_role(
2210 role: &str,
2211 element: &ElementRef,
2212 ) -> bool {
2213 static VALID_ROLES: Lazy<HashMap<&str, Vec<&str>>> =
2214 Lazy::new(|| {
2215 let mut map = HashMap::new();
2216 _ = map.insert(
2217 "button",
2218 vec!["button", "link", "menuitem"],
2219 );
2220 _ = map.insert(
2221 "input",
2222 vec!["textbox", "radio", "checkbox", "button"],
2223 );
2224 _ = map.insert(
2225 "div",
2226 vec!["alert", "tooltip", "dialog", "slider"],
2227 );
2228 _ = map.insert("a", vec!["link", "button", "menuitem"]);
2229 map
2230 });
2231
2232 let tag_name = element.value().name();
2234 if ["div", "span", "a"].contains(&tag_name) {
2235 return true;
2236 }
2237
2238 if let Some(valid_roles) = VALID_ROLES.get(tag_name) {
2240 valid_roles.contains(&role)
2241 } else {
2242 false
2243 }
2244 }
2245
2246 pub(crate) fn get_missing_required_aria_properties(
2248 element: &ElementRef,
2249 ) -> Option<Vec<String>> {
2250 let mut missing = Vec::new();
2251
2252 static REQUIRED_ARIA_PROPS: Lazy<HashMap<&str, Vec<&str>>> =
2253 Lazy::new(|| {
2254 HashMap::from([
2255 (
2256 "slider",
2257 vec![
2258 "aria-valuenow",
2259 "aria-valuemin",
2260 "aria-valuemax",
2261 ],
2262 ),
2263 ("combobox", vec!["aria-expanded"]),
2264 ])
2265 });
2266
2267 if let Some(role) = element.value().attr("role") {
2268 if let Some(required_props) = REQUIRED_ARIA_PROPS.get(role)
2269 {
2270 for prop in required_props {
2271 if element.value().attr(prop).is_none() {
2272 missing.push((*prop).to_string());
2273 }
2274 }
2275 }
2276 }
2277
2278 if missing.is_empty() {
2279 None
2280 } else {
2281 Some(missing)
2282 }
2283 }
2284}
2285
2286#[cfg(test)]
2287mod tests {
2288 use super::*;
2289
2290 mod dom_replacer_fallbacks {
2293 use super::*;
2294
2295 #[test]
2296 fn direct_match_hits_the_fast_path() {
2297 let mut r = DomReplacer::new("<p>one</p><p>two</p>");
2298 r.push("<p>one</p>", "<p>ONE</p>");
2299 r.push("<p>two</p>", "<p>TWO</p>");
2300 assert_eq!(r.apply(), "<p>ONE</p><p>TWO</p>");
2301 }
2302
2303 #[test]
2304 fn shorthand_fallback_anchors_via_tag_name() {
2305 let source = r#"<form><input disabled type="text"></form>"#;
2310 let mut r = DomReplacer::new(source);
2311 r.push(
2312 r#"<input disabled="" type="text">"#,
2313 r#"<input disabled aria-disabled="true" type="text">"#,
2314 );
2315 let out = r.apply();
2316 assert!(
2317 out.contains("aria-disabled"),
2318 "shorthand fallback should patch source: {out}"
2319 );
2320 }
2321
2322 #[test]
2323 fn last_resort_path_fires_when_fallback_misses() {
2324 let mut r = DomReplacer::new("<p>unchanged</p>");
2329 r.push("<nonexistent></nonexistent>", "<whatever/>");
2330 assert_eq!(r.apply(), "<p>unchanged</p>");
2331 }
2332
2333 #[test]
2334 fn duplicate_identical_snippets_map_to_distinct_occurrences() {
2335 let source = "<span>x</span><span>x</span>";
2339 let mut r = DomReplacer::new(source);
2340 r.push("<span>x</span>", "<span>A</span>");
2341 r.push("<span>x</span>", "<span>B</span>");
2342 assert_eq!(r.apply(), "<span>A</span><span>B</span>");
2343 }
2344
2345 #[test]
2346 fn extract_opening_tag_handles_self_terminating_input() {
2347 assert_eq!(
2350 extract_opening_tag(r#"<button id="x">click</button>"#),
2351 "<button"
2352 );
2353 assert_eq!(extract_opening_tag(r#"<input/>"#), "<input");
2355 assert_eq!(extract_opening_tag("<naked"), "<naked");
2359 assert_eq!(extract_opening_tag("naked"), "<naked");
2360 }
2361
2362 #[test]
2363 fn shorthand_normalization_fires_closure_on_queued_old_html() {
2364 let source =
2372 r#"<form><input disabled="" type="text"></form>"#;
2373 let mut r = DomReplacer::new(source);
2374 r.push(
2375 r#"<input disabled type="text">"#,
2376 r#"<input disabled aria-disabled="true" type="text">"#,
2377 );
2378 let out = r.apply();
2379 assert!(
2384 out.contains("aria-disabled") || out == source,
2385 "shorthand closure on old_html should have run: {out}"
2386 );
2387 }
2388 }
2389
2390 mod wcag_level_tests {
2392 use super::*;
2393
2394 #[test]
2395 fn test_wcag_level_ordering() {
2396 assert!(matches!(WcagLevel::A, WcagLevel::A));
2397 assert!(matches!(WcagLevel::AA, WcagLevel::AA));
2398 assert!(matches!(WcagLevel::AAA, WcagLevel::AAA));
2399 }
2400
2401 #[test]
2402 fn test_wcag_level_debug() {
2403 assert_eq!(format!("{:?}", WcagLevel::A), "A");
2404 assert_eq!(format!("{:?}", WcagLevel::AA), "AA");
2405 assert_eq!(format!("{:?}", WcagLevel::AAA), "AAA");
2406 }
2407 }
2408
2409 mod config_tests {
2411 use super::*;
2412
2413 #[test]
2414 fn test_default_config() {
2415 let config = AccessibilityConfig::default();
2416 assert_eq!(config.wcag_level, WcagLevel::AA);
2417 assert_eq!(config.max_heading_jump, 1);
2418 assert_eq!(config.min_contrast_ratio, 4.5);
2419 assert!(config.auto_fix);
2420 }
2421
2422 #[test]
2423 fn test_custom_config() {
2424 let config = AccessibilityConfig {
2425 wcag_level: WcagLevel::AAA,
2426 max_heading_jump: 2,
2427 min_contrast_ratio: 7.0,
2428 auto_fix: false,
2429 };
2430 assert_eq!(config.wcag_level, WcagLevel::AAA);
2431 assert_eq!(config.max_heading_jump, 2);
2432 assert_eq!(config.min_contrast_ratio, 7.0);
2433 assert!(!config.auto_fix);
2434 }
2435 }
2436
2437 mod aria_attribute_tests {
2439 use super::*;
2440
2441 #[test]
2442 fn test_valid_aria_attributes() {
2443 assert!(is_valid_aria_attribute("aria-label", "Test"));
2444 assert!(is_valid_aria_attribute("aria-hidden", "true"));
2445 assert!(is_valid_aria_attribute("aria-hidden", "false"));
2446 assert!(!is_valid_aria_attribute("aria-hidden", "yes"));
2447 assert!(!is_valid_aria_attribute("invalid-aria", "value"));
2448 }
2449
2450 #[test]
2451 fn test_empty_aria_value() {
2452 assert!(!is_valid_aria_attribute("aria-label", ""));
2453 assert!(!is_valid_aria_attribute("aria-label", " "));
2454 }
2455 }
2456
2457 mod html_modification_tests {
2459 use super::*;
2460
2461 #[test]
2462 fn test_add_aria_to_empty_button() {
2463 let html = "<button></button>";
2464 let result = add_aria_attributes(html, None);
2465 assert!(result.is_ok());
2466 let enhanced = result.unwrap();
2467 assert!(enhanced.contains(r#"aria-label="button""#));
2468 }
2469
2470 #[test]
2471 fn test_large_input() {
2472 let large_html = "a".repeat(MAX_HTML_SIZE + 1);
2473 let result = add_aria_attributes(&large_html, None);
2474 assert!(matches!(result, Err(Error::HtmlTooLarge { .. })));
2475 }
2476 }
2477
2478 mod validation_tests {
2480 use super::*;
2481
2482 #[test]
2483 fn test_heading_structure() {
2484 let valid_html = "<h1>Main Title</h1><h2>Subtitle</h2>";
2485 let invalid_html =
2486 "<h1>Main Title</h1><h3>Skipped Heading</h3>";
2487
2488 let config = AccessibilityConfig::default();
2489
2490 let valid_result = validate_wcag(
2492 valid_html,
2493 &config,
2494 Some(&[IssueType::LanguageDeclaration]),
2495 )
2496 .unwrap();
2497 assert_eq!(
2498 valid_result.issue_count, 0,
2499 "Expected no issues for valid HTML, but found: {:#?}",
2500 valid_result.issues
2501 );
2502
2503 let invalid_result = validate_wcag(
2505 invalid_html,
2506 &config,
2507 Some(&[IssueType::LanguageDeclaration]),
2508 )
2509 .unwrap();
2510 assert_eq!(
2511 invalid_result.issue_count,
2512 1,
2513 "Expected one issue for skipped heading levels, but found: {:#?}",
2514 invalid_result.issues
2515 );
2516
2517 let issue = &invalid_result.issues[0];
2518 assert_eq!(issue.issue_type, IssueType::HeadingStructure);
2519 assert_eq!(
2520 issue.message,
2521 "Skipped heading level from h1 to h3"
2522 );
2523 assert_eq!(issue.guideline, Some("WCAG 2.4.6".to_string()));
2524 assert_eq!(
2525 issue.suggestion,
2526 Some("Use sequential heading levels".to_string())
2527 );
2528 }
2529 }
2530
2531 mod report_tests {
2533 use super::*;
2534
2535 #[test]
2536 fn test_report_generation() {
2537 let html = r#"<img src="test.jpg">"#;
2538 let config = AccessibilityConfig::default();
2539 let report = validate_wcag(html, &config, None).unwrap();
2540
2541 assert!(report.issue_count > 0);
2542
2543 assert_eq!(report.wcag_level, WcagLevel::AA);
2544 }
2545
2546 #[test]
2547 fn test_empty_html_report() {
2548 let html = "";
2549 let config = AccessibilityConfig::default();
2550 let report = validate_wcag(html, &config, None).unwrap();
2551
2552 assert_eq!(report.elements_checked, 0);
2553 assert_eq!(report.issue_count, 0);
2554 }
2555
2556 #[test]
2557 fn test_missing_selector_handling() {
2558 static TEST_NAV_SELECTOR: Lazy<Option<Selector>> =
2560 Lazy::new(|| None);
2561
2562 let html = "<nav>Main Navigation</nav>";
2563 let document = Html::parse_document(html);
2564
2565 if let Some(selector) = TEST_NAV_SELECTOR.as_ref() {
2566 let navs: Vec<_> = document.select(selector).collect();
2567 assert_eq!(navs.len(), 0);
2568 }
2569 }
2570
2571 #[test]
2572 fn test_html_processing_error_with_source() {
2573 let source_error =
2574 std::io::Error::other("test source error");
2575 let error = Error::HtmlProcessingError {
2576 message: "Processing failed".to_string(),
2577 source: Some(Box::new(source_error)),
2578 };
2579
2580 assert_eq!(
2581 format!("{}", error),
2582 "HTML Processing Error: Processing failed"
2583 );
2584 }
2585 }
2586 #[cfg(test)]
2587 mod utils_tests {
2588 use super::*;
2589
2590 mod language_code_validation {
2591 use super::*;
2592
2593 #[test]
2594 fn test_valid_language_codes() {
2595 let valid_codes = [
2596 "en", "en-US", "zh-CN", "fr-FR", "de-DE", "es-419",
2597 "ar-001", "pt-BR", "ja-JP", "ko-KR",
2598 ];
2599 for code in valid_codes {
2600 assert!(
2601 is_valid_language_code(code),
2602 "Language code '{}' should be valid",
2603 code
2604 );
2605 }
2606 }
2607
2608 #[test]
2609 fn test_invalid_language_codes() {
2610 let invalid_codes = [
2611 "", "a", "123", "en_US", "en-", "-en", "en--US", "toolong", "en-US-INVALID-", ];
2621 for code in invalid_codes {
2622 assert!(
2623 !is_valid_language_code(code),
2624 "Language code '{}' should be invalid",
2625 code
2626 );
2627 }
2628 }
2629
2630 #[test]
2631 fn test_language_code_case_sensitivity() {
2632 assert!(is_valid_language_code("en-GB"));
2633 assert!(is_valid_language_code("fr-FR"));
2634 assert!(is_valid_language_code("zh-Hans"));
2635 assert!(is_valid_language_code("EN-GB"));
2636 }
2637 }
2638
2639 mod aria_role_validation {
2640 use super::*;
2641
2642 #[test]
2643 fn test_valid_button_roles() {
2644 let html = "<button>Test</button>";
2645 let fragment = Html::parse_fragment(html);
2646 let selector = Selector::parse("button").unwrap();
2647 let element =
2648 fragment.select(&selector).next().unwrap();
2649 let valid_roles = ["button", "link", "menuitem"];
2650 for role in valid_roles {
2651 assert!(
2652 is_valid_aria_role(role, &element),
2653 "Role '{}' should be valid for button",
2654 role
2655 );
2656 }
2657 }
2658
2659 #[test]
2660 fn test_valid_input_roles() {
2661 let html = "<input type='text'>";
2662 let fragment = Html::parse_fragment(html);
2663 let selector = Selector::parse("input").unwrap();
2664 let element =
2665 fragment.select(&selector).next().unwrap();
2666 let valid_roles =
2667 ["textbox", "radio", "checkbox", "button"];
2668 for role in valid_roles {
2669 assert!(
2670 is_valid_aria_role(role, &element),
2671 "Role '{}' should be valid for input",
2672 role
2673 );
2674 }
2675 }
2676
2677 #[test]
2678 fn test_valid_anchor_roles() {
2679 let html = "<a href=\"\\#\">Test</a>";
2680 let fragment = Html::parse_fragment(html);
2681 let selector = Selector::parse("a").unwrap();
2682 let element =
2683 fragment.select(&selector).next().unwrap();
2684
2685 let valid_roles = ["button", "link", "menuitem"];
2686 for role in valid_roles {
2687 assert!(
2688 is_valid_aria_role(role, &element),
2689 "Role '{}' should be valid for anchor",
2690 role
2691 );
2692 }
2693 }
2694
2695 #[test]
2696 fn test_invalid_element_roles() {
2697 let html = "<button>Test</button>";
2698 let fragment = Html::parse_fragment(html);
2699 let selector = Selector::parse("button").unwrap();
2700 let element =
2701 fragment.select(&selector).next().unwrap();
2702 let invalid_roles =
2703 ["textbox", "radio", "checkbox", "invalid"];
2704 for role in invalid_roles {
2705 assert!(
2706 !is_valid_aria_role(role, &element),
2707 "Role '{}' should be invalid for button",
2708 role
2709 );
2710 }
2711 }
2712
2713 #[test]
2714 fn test_unrestricted_elements() {
2715 let html_div = "<div>Test</div>";
2717 let fragment_div = Html::parse_fragment(html_div);
2718 let selector_div = Selector::parse("div").unwrap();
2719 let element_div =
2720 fragment_div.select(&selector_div).next().unwrap();
2721
2722 let html_span = "<span>Test</span>";
2724 let fragment_span = Html::parse_fragment(html_span);
2725 let selector_span = Selector::parse("span").unwrap();
2726 let element_span = fragment_span
2727 .select(&selector_span)
2728 .next()
2729 .unwrap();
2730
2731 let roles =
2732 ["button", "textbox", "navigation", "banner"];
2733
2734 for role in roles {
2735 assert!(
2736 is_valid_aria_role(role, &element_div),
2737 "Role '{}' should be allowed for div",
2738 role
2739 );
2740 assert!(
2741 is_valid_aria_role(role, &element_span),
2742 "Role '{}' should be allowed for span",
2743 role
2744 );
2745 }
2746 }
2747
2748 #[test]
2749 fn test_validate_wcag_with_level_aaa() {
2750 let html =
2751 "<h1>Main Title</h1><h3>Skipped Heading</h3>";
2752 let config = AccessibilityConfig {
2753 wcag_level: WcagLevel::AAA,
2754 ..Default::default()
2755 };
2756 let report =
2757 validate_wcag(html, &config, None).unwrap();
2758 assert!(report.issue_count > 0);
2759 assert_eq!(report.wcag_level, WcagLevel::AAA);
2760 }
2761
2762 #[test]
2763 fn test_html_builder_empty() {
2764 let builder = HtmlBuilder::new("");
2765 assert_eq!(builder.build(), "");
2766 }
2767
2768 #[test]
2769 fn test_generate_unique_id_uniqueness() {
2770 let id1 = generate_unique_id();
2771 let id2 = generate_unique_id();
2772 assert_ne!(id1, id2);
2773 }
2774 }
2775
2776 mod required_aria_properties {
2777 use super::*;
2778 use scraper::{Html, Selector};
2779
2780 #[test]
2781 fn test_combobox_required_properties() {
2782 let html = r#"<div role="combobox">Test</div>"#;
2783 let fragment = Html::parse_fragment(html);
2784 let selector = Selector::parse("div").unwrap();
2785 let element =
2786 fragment.select(&selector).next().unwrap();
2787
2788 let missing =
2789 get_missing_required_aria_properties(&element)
2790 .unwrap();
2791 assert!(missing.contains(&"aria-expanded".to_string()));
2792 }
2793
2794 #[test]
2795 fn test_complete_combobox() {
2796 let html = r#"<div role="combobox" aria-expanded="true">Test</div>"#;
2797 let fragment = Html::parse_fragment(html);
2798 let selector = Selector::parse("div").unwrap();
2799 let element =
2800 fragment.select(&selector).next().unwrap();
2801
2802 let missing =
2803 get_missing_required_aria_properties(&element);
2804 assert!(missing.is_none());
2805 }
2806
2807 #[test]
2808 fn test_add_aria_attributes_empty_html() {
2809 let html = "";
2810 let result = add_aria_attributes(html, None);
2811 assert!(result.is_ok());
2812 assert_eq!(result.unwrap(), "");
2813 }
2814
2815 #[test]
2816 fn test_add_aria_attributes_whitespace_html() {
2817 let html = " ";
2818 let result = add_aria_attributes(html, None);
2819 assert!(result.is_ok());
2820 assert_eq!(result.unwrap(), " ");
2821 }
2822
2823 #[test]
2824 fn test_validate_wcag_with_minimal_config() {
2825 let html = r#"<html lang="en"><div>Accessible Content</div></html>"#;
2826 let config = AccessibilityConfig {
2827 wcag_level: WcagLevel::A,
2828 max_heading_jump: 0, min_contrast_ratio: 0.0, auto_fix: false,
2831 };
2832 let report =
2833 validate_wcag(html, &config, None).unwrap();
2834 assert_eq!(report.issue_count, 0);
2835 }
2836
2837 #[test]
2838 fn test_add_partial_aria_attributes_to_button() {
2839 let html =
2840 r#"<button aria-label="Existing">Click</button>"#;
2841 let result = add_aria_attributes(html, None);
2842 assert!(result.is_ok());
2843 let enhanced = result.unwrap();
2844 assert!(enhanced.contains(r#"aria-label="Existing""#));
2845 }
2846
2847 #[test]
2848 fn test_add_aria_to_elements_with_existing_roles() {
2849 let html = r#"<nav aria-label=\"navigation\" role=\"navigation\" role=\"navigation\">Content</nav>"#;
2850 let result = add_aria_attributes(html, None);
2851 assert!(result.is_ok());
2852 assert_eq!(result.unwrap(), html);
2853 }
2854
2855 #[test]
2856 fn test_slider_required_properties() {
2857 let html = r#"<div role="slider">Test</div>"#;
2858 let fragment = Html::parse_fragment(html);
2859 let selector = Selector::parse("div").unwrap();
2860 let element =
2861 fragment.select(&selector).next().unwrap();
2862
2863 let missing =
2864 get_missing_required_aria_properties(&element)
2865 .unwrap();
2866
2867 assert!(missing.contains(&"aria-valuenow".to_string()));
2868 assert!(missing.contains(&"aria-valuemin".to_string()));
2869 assert!(missing.contains(&"aria-valuemax".to_string()));
2870 }
2871
2872 #[test]
2873 fn test_complete_slider() {
2874 let html = r#"<div role="slider"
2875 aria-valuenow="50"
2876 aria-valuemin="0"
2877 aria-valuemax="100">Test</div>"#;
2878 let fragment = Html::parse_fragment(html);
2879 let selector = Selector::parse("div").unwrap();
2880 let element =
2881 fragment.select(&selector).next().unwrap();
2882
2883 let missing =
2884 get_missing_required_aria_properties(&element);
2885 assert!(missing.is_none());
2886 }
2887
2888 #[test]
2889 fn test_partial_slider_properties() {
2890 let html = r#"<div role="slider" aria-valuenow="50">Test</div>"#;
2891 let fragment = Html::parse_fragment(html);
2892 let selector = Selector::parse("div").unwrap();
2893 let element =
2894 fragment.select(&selector).next().unwrap();
2895
2896 let missing =
2897 get_missing_required_aria_properties(&element)
2898 .unwrap();
2899
2900 assert!(!missing.contains(&"aria-valuenow".to_string()));
2901 assert!(missing.contains(&"aria-valuemin".to_string()));
2902 assert!(missing.contains(&"aria-valuemax".to_string()));
2903 }
2904
2905 #[test]
2906 fn test_unknown_role() {
2907 let html = r#"<div role="unknown">Test</div>"#;
2908 let fragment = Html::parse_fragment(html);
2909 let selector = Selector::parse("div").unwrap();
2910 let element =
2911 fragment.select(&selector).next().unwrap();
2912
2913 let missing =
2914 get_missing_required_aria_properties(&element);
2915 assert!(missing.is_none());
2916 }
2917
2918 #[test]
2919 fn test_no_role() {
2920 let html = "<div>Test</div>";
2921 let fragment = Html::parse_fragment(html);
2922 let selector = Selector::parse("div").unwrap();
2923 let element =
2924 fragment.select(&selector).next().unwrap();
2925
2926 let missing =
2927 get_missing_required_aria_properties(&element);
2928 assert!(missing.is_none());
2929 }
2930 }
2931 }
2932
2933 #[cfg(test)]
2934 mod accessibility_tests {
2935 use crate::accessibility::{
2936 get_missing_required_aria_properties, is_valid_aria_role,
2937 is_valid_language_code,
2938 };
2939 use scraper::Selector;
2940
2941 #[test]
2942 fn test_is_valid_language_code() {
2943 assert!(
2944 is_valid_language_code("en"),
2945 "Valid language code 'en' was incorrectly rejected"
2946 );
2947 assert!(
2948 is_valid_language_code("en-US"),
2949 "Valid language code 'en-US' was incorrectly rejected"
2950 );
2951 assert!(
2952 !is_valid_language_code("123"),
2953 "Invalid language code '123' was incorrectly accepted"
2954 );
2955 assert!(!is_valid_language_code("日本語"), "Non-ASCII language code '日本語' was incorrectly accepted");
2956 }
2957
2958 #[test]
2959 fn test_is_valid_aria_role() {
2960 use scraper::Html;
2961
2962 let html = r#"<button></button>"#;
2963 let document = Html::parse_fragment(html);
2964 let element = document
2965 .select(&Selector::parse("button").unwrap())
2966 .next()
2967 .unwrap();
2968
2969 assert!(
2970 is_valid_aria_role("button", &element),
2971 "Valid ARIA role 'button' was incorrectly rejected"
2972 );
2973
2974 assert!(
2975 !is_valid_aria_role("invalid-role", &element),
2976 "Invalid ARIA role 'invalid-role' was incorrectly accepted"
2977 );
2978 }
2979
2980 #[test]
2981 fn test_get_missing_required_aria_properties() {
2982 use scraper::{Html, Selector};
2983
2984 let html = r#"<div role="slider"></div>"#;
2986 let document = Html::parse_fragment(html);
2987 let element = document
2988 .select(&Selector::parse("div").unwrap())
2989 .next()
2990 .unwrap();
2991
2992 let missing_props =
2993 get_missing_required_aria_properties(&element).unwrap();
2994 assert!(
2995 missing_props.contains(&"aria-valuenow".to_string()),
2996 "Did not detect missing 'aria-valuenow' for role 'slider'"
2997 );
2998 assert!(
2999 missing_props.contains(&"aria-valuemin".to_string()),
3000 "Did not detect missing 'aria-valuemin' for role 'slider'"
3001 );
3002 assert!(
3003 missing_props.contains(&"aria-valuemax".to_string()),
3004 "Did not detect missing 'aria-valuemax' for role 'slider'"
3005 );
3006
3007 let html = r#"<div role="slider" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>"#;
3009 let document = Html::parse_fragment(html);
3010 let element = document
3011 .select(&Selector::parse("div").unwrap())
3012 .next()
3013 .unwrap();
3014
3015 let missing_props =
3016 get_missing_required_aria_properties(&element);
3017 assert!(missing_props.is_none(), "Unexpectedly found missing properties for a complete slider");
3018
3019 let html =
3021 r#"<div role="slider" aria-valuenow="50"></div>"#;
3022 let document = Html::parse_fragment(html);
3023 let element = document
3024 .select(&Selector::parse("div").unwrap())
3025 .next()
3026 .unwrap();
3027
3028 let missing_props =
3029 get_missing_required_aria_properties(&element).unwrap();
3030 assert!(
3031 !missing_props.contains(&"aria-valuenow".to_string()),
3032 "Incorrectly flagged 'aria-valuenow' as missing"
3033 );
3034 assert!(
3035 missing_props.contains(&"aria-valuemin".to_string()),
3036 "Did not detect missing 'aria-valuemin' for role 'slider'"
3037 );
3038 assert!(
3039 missing_props.contains(&"aria-valuemax".to_string()),
3040 "Did not detect missing 'aria-valuemax' for role 'slider'"
3041 );
3042 }
3043 }
3044
3045 #[cfg(test)]
3046 mod additional_tests {
3047 use super::*;
3048 use scraper::Html;
3049
3050 #[test]
3051 fn test_validate_empty_html() {
3052 let html = "";
3053 let config = AccessibilityConfig::default();
3054 let report = validate_wcag(html, &config, None).unwrap();
3055 assert_eq!(
3056 report.issue_count, 0,
3057 "Empty HTML should not produce issues"
3058 );
3059 }
3060
3061 #[test]
3062 fn test_validate_only_whitespace_html() {
3063 let html = " ";
3064 let config = AccessibilityConfig::default();
3065 let report = validate_wcag(html, &config, None).unwrap();
3066 assert_eq!(
3067 report.issue_count, 0,
3068 "Whitespace-only HTML should not produce issues"
3069 );
3070 }
3071
3072 #[test]
3073 fn test_validate_language_with_edge_cases() {
3074 let html = "<html lang=\"en-US\"></html>";
3075 let _config = AccessibilityConfig::default();
3076 let mut issues = Vec::new();
3077 let document = Html::parse_document(html);
3078
3079 AccessibilityReport::check_language_attributes(
3080 &document,
3081 &mut issues,
3082 )
3083 .unwrap();
3084 assert_eq!(
3085 issues.len(),
3086 0,
3087 "Valid language declaration should not create issues"
3088 );
3089 }
3090
3091 #[test]
3092 fn test_validate_invalid_language_code() {
3093 let html = "<html lang=\"invalid-lang\"></html>";
3094 let _config = AccessibilityConfig::default();
3095 let mut issues = Vec::new();
3096 let document = Html::parse_document(html);
3097
3098 AccessibilityReport::check_language_attributes(
3099 &document,
3100 &mut issues,
3101 )
3102 .unwrap();
3103 assert!(
3104 issues
3105 .iter()
3106 .any(|i| i.issue_type
3107 == IssueType::LanguageDeclaration),
3108 "Failed to detect invalid language declaration"
3109 );
3110 }
3111
3112 #[test]
3113 fn test_edge_case_for_generate_unique_id() {
3114 let ids: Vec<String> =
3115 (0..100).map(|_| generate_unique_id()).collect();
3116 let unique_ids: HashSet<String> = ids.into_iter().collect();
3117 assert_eq!(
3118 unique_ids.len(),
3119 100,
3120 "Generated IDs are not unique in edge case testing"
3121 );
3122 }
3123
3124 #[test]
3125 fn test_html_with_non_standard_elements() {
3126 let html =
3127 "<custom-element aria-label=\"test\"></custom-element>";
3128 let cleaned_html = remove_invalid_aria_attributes(html);
3129 assert_eq!(cleaned_html, html, "Unexpectedly modified valid custom element with ARIA attributes");
3130 }
3131
3132 #[test]
3133 fn test_add_aria_to_buttons() {
3134 let html = r#"<button>Click me</button>"#;
3135 let builder = HtmlBuilder::new(html);
3136 let result = add_aria_to_buttons(builder).unwrap().build();
3137 assert!(result.contains("aria-label"));
3138 }
3139
3140 #[test]
3141 fn test_add_aria_to_empty_buttons() {
3142 let html = r#"<button></button>"#;
3143 let builder = HtmlBuilder::new(html);
3144 let result = add_aria_to_buttons(builder).unwrap();
3145 assert!(result.build().contains("aria-label"));
3146 }
3147
3148 #[test]
3149 fn test_validate_wcag_empty_html() {
3150 let html = "";
3151 let config = AccessibilityConfig::default();
3152 let disable_checks = None;
3153
3154 let result = validate_wcag(html, &config, disable_checks);
3155
3156 match result {
3157 Ok(report) => assert!(
3158 report.issues.is_empty(),
3159 "Empty HTML should have no issues"
3160 ),
3161 Err(e) => {
3162 panic!("Validation failed with error: {:?}", e)
3163 }
3164 }
3165 }
3166
3167 #[test]
3168 fn test_validate_wcag_with_complex_html() {
3169 let html = "
3170 <html>
3171 <head></head>
3172 <body>
3173 <button>Click me</button>
3174 <a href=\"\\#\"></a>
3175 </body>
3176 </html>
3177 ";
3178 let config = AccessibilityConfig::default();
3179 let disable_checks = None;
3180 let result = validate_wcag(html, &config, disable_checks);
3181
3182 match result {
3183 Ok(report) => assert!(
3184 !report.issues.is_empty(),
3185 "Report should have issues"
3186 ),
3187 Err(e) => {
3188 panic!("Validation failed with error: {:?}", e)
3189 }
3190 }
3191 }
3192
3193 #[test]
3194 fn test_generate_unique_id_uniqueness() {
3195 let id1 = generate_unique_id();
3196 let id2 = generate_unique_id();
3197 assert_ne!(id1, id2);
3198 }
3199
3200 #[test]
3201 fn test_try_create_selector_valid() {
3202 let selector = try_create_selector("div.class");
3205 let html = scraper::Html::parse_fragment(
3206 r#"<div class="class">x</div>"#,
3207 );
3208 assert!(html.select(&selector).next().is_some());
3209 }
3210
3211 #[test]
3212 #[should_panic(expected = "static selector")]
3213 fn test_try_create_selector_invalid_panics() {
3214 let _ = try_create_selector("div..class");
3217 }
3218
3219 #[test]
3220 fn test_try_create_regex_valid() {
3221 let regex = try_create_regex(r"\d+");
3223 assert!(regex.is_match("42"));
3224 }
3225
3226 #[test]
3227 #[should_panic(expected = "static regex")]
3228 fn test_try_create_regex_invalid_panics() {
3229 let _ = try_create_regex(r"\d+(");
3232 }
3233
3234 #[test]
3236 fn test_error_from_try_from_int_error() {
3237 let result: std::result::Result<u8, _> = i32::try_into(300); let err = result.unwrap_err(); let error: Error = Error::from(err);
3241
3242 if let Error::HtmlProcessingError { message, source } =
3243 error
3244 {
3245 assert_eq!(message, "Integer conversion error");
3246 assert!(source.is_some());
3247 } else {
3248 panic!("Expected HtmlProcessingError");
3249 }
3250 }
3251
3252 #[test]
3254 fn test_wcag_level_display() {
3255 assert_eq!(WcagLevel::A.to_string(), "A");
3256 assert_eq!(WcagLevel::AA.to_string(), "AA");
3257 assert_eq!(WcagLevel::AAA.to_string(), "AAA");
3258 }
3259
3260 #[test]
3262 fn test_check_keyboard_navigation() {
3263 let document =
3264 Html::parse_document("<a tabindex='-1'></a>");
3265 let mut issues = vec![];
3266 let result = AccessibilityReport::check_keyboard_navigation(
3267 &document,
3268 &mut issues,
3269 );
3270 assert!(result.is_ok());
3271 assert_eq!(issues.len(), 1);
3272 assert_eq!(
3273 issues[0].message,
3274 "Negative tabindex prevents keyboard focus"
3275 );
3276 }
3277
3278 #[test]
3280 fn test_check_language_attributes() {
3281 let document = Html::parse_document("<html></html>");
3282 let mut issues = vec![];
3283 let result = AccessibilityReport::check_language_attributes(
3284 &document,
3285 &mut issues,
3286 );
3287 assert!(result.is_ok());
3288 assert_eq!(issues.len(), 1);
3289 assert_eq!(
3290 issues[0].message,
3291 "Missing language declaration"
3292 );
3293 }
3294 }
3295
3296 mod missing_tests {
3297 use super::*;
3298 use std::collections::HashSet;
3299
3300 #[test]
3302 fn test_color_contrast_ratio() {
3303 let low_contrast = 2.5;
3304 let high_contrast = 7.1;
3305
3306 let config = AccessibilityConfig {
3307 min_contrast_ratio: 4.5,
3308 ..Default::default()
3309 };
3310
3311 assert!(
3312 low_contrast < config.min_contrast_ratio,
3313 "Low contrast should not pass"
3314 );
3315
3316 assert!(
3317 high_contrast >= config.min_contrast_ratio,
3318 "High contrast should pass"
3319 );
3320 }
3321
3322 #[test]
3324 fn test_dynamic_content_aria_attributes() {
3325 let html = r#"<div aria-live="polite"></div>"#;
3326 let cleaned_html = remove_invalid_aria_attributes(html);
3327 assert_eq!(
3328 cleaned_html, html,
3329 "Dynamic content ARIA attributes should be preserved"
3330 );
3331 }
3332
3333 #[test]
3335 fn test_strict_wcag_aaa_behavior() {
3336 let html = r#"<h1>Main Title</h1><h4>Skipped Level</h4>"#;
3337 let config = AccessibilityConfig {
3338 wcag_level: WcagLevel::AAA,
3339 ..Default::default()
3340 };
3341
3342 let report = validate_wcag(html, &config, None).unwrap();
3343 assert!(
3344 report.issue_count > 0,
3345 "WCAG AAA strictness should detect issues"
3346 );
3347
3348 let issue = &report.issues[0];
3349 assert_eq!(
3350 issue.issue_type,
3351 IssueType::LanguageDeclaration,
3352 "Expected heading structure issue"
3353 );
3354 }
3355
3356 #[test]
3358 fn test_large_html_performance() {
3359 let large_html =
3360 "<div>".repeat(1_000) + &"</div>".repeat(1_000);
3361 let result = validate_wcag(
3362 &large_html,
3363 &AccessibilityConfig::default(),
3364 None,
3365 );
3366 assert!(
3367 result.is_ok(),
3368 "Large HTML should not cause performance issues"
3369 );
3370 }
3371
3372 #[test]
3374 fn test_nested_elements_with_aria_attributes() {
3375 let html = r#"
3376 <div>
3377 <button aria-label="Test">Click</button>
3378 <nav aria-label="Main Navigation">
3379 <ul><li>Item 1</li></ul>
3380 </nav>
3381 </div>
3382 "#;
3383 let enhanced_html =
3384 add_aria_attributes(html, None).unwrap();
3385 assert!(
3386 enhanced_html.contains("aria-label"),
3387 "Nested elements should have ARIA attributes"
3388 );
3389 }
3390
3391 #[test]
3393 fn test_deeply_nested_headings() {
3394 let html = r#"
3395 <div>
3396 <h1>Main Title</h1>
3397 <div>
3398 <h3>Skipped Level</h3>
3399 </div>
3400 </div>
3401 "#;
3402 let mut issues = Vec::new();
3403 let document = Html::parse_document(html);
3404 check_heading_structure(&document, &mut issues);
3405
3406 assert!(
3407 issues.iter().any(|issue| issue.issue_type == IssueType::HeadingStructure),
3408 "Deeply nested headings with skipped levels should produce issues"
3409 );
3410 }
3411
3412 #[test]
3414 fn test_unique_id_long_runtime() {
3415 let ids: HashSet<_> =
3416 (0..10_000).map(|_| generate_unique_id()).collect();
3417 assert_eq!(
3418 ids.len(),
3419 10_000,
3420 "Generated IDs should be unique over long runtime"
3421 );
3422 }
3423
3424 #[test]
3426 #[should_panic(expected = "static selector")]
3427 fn test_custom_selector_failure_panics() {
3428 let _ = try_create_selector("div..class");
3429 }
3430
3431 #[test]
3433 #[should_panic(expected = "static regex")]
3434 fn test_invalid_regex_pattern_panics() {
3435 let _ = try_create_regex(r"\d+(");
3436 }
3437
3438 #[test]
3440 fn test_invalid_aria_attribute_removal() {
3441 let html = r#"<div aria-hidden="invalid"></div>"#;
3442 let cleaned_html = remove_invalid_aria_attributes(html);
3443 assert!(
3444 !cleaned_html.contains("aria-hidden"),
3445 "Invalid ARIA attributes should be removed"
3446 );
3447 }
3448
3449 #[test]
3452 #[should_panic(expected = "static selector")]
3453 fn test_invalid_selector_panics() {
3454 let _ = try_create_selector("div..class");
3455 }
3456
3457 #[test]
3459 fn test_issue_type_in_issue_struct() {
3460 let issue = Issue {
3461 issue_type: IssueType::MissingAltText,
3462 message: "Alt text is missing".to_string(),
3463 guideline: Some("WCAG 1.1.1".to_string()),
3464 element: Some("<img>".to_string()),
3465 suggestion: Some(
3466 "Add descriptive alt text".to_string(),
3467 ),
3468 };
3469 assert_eq!(issue.issue_type, IssueType::MissingAltText);
3470 }
3471
3472 #[test]
3474 fn test_add_aria_to_navs() {
3475 let html = "<nav>Main Navigation</nav>";
3476 let builder = HtmlBuilder::new(html);
3477 let result = add_aria_to_navs(builder).unwrap().build();
3478 assert!(result.contains(r#"aria-label="navigation""#));
3479 assert!(result.contains(r#"role="navigation""#));
3480 }
3481
3482 #[test]
3484 fn test_add_aria_to_forms() {
3485 let html = r#"<form>Form Content</form>"#;
3486 let result =
3487 add_aria_to_forms(HtmlBuilder::new(html)).unwrap();
3488 let content = result.build();
3489
3490 assert!(content.contains(r#"id="form-"#));
3491 assert!(content.contains(r#"aria-labelledby="form-"#));
3492 }
3493
3494 #[test]
3496 fn test_check_keyboard_navigation_click_handlers() {
3497 let html = r#"<button onclick="handleClick()"></button>"#;
3498 let document = Html::parse_document(html);
3499 let mut issues = vec![];
3500
3501 AccessibilityReport::check_keyboard_navigation(
3502 &document,
3503 &mut issues,
3504 )
3505 .unwrap();
3506
3507 assert!(
3508 issues.iter().any(|i| i.message == "Click handler without keyboard equivalent"),
3509 "Expected an issue for missing keyboard equivalents, but found: {:?}",
3510 issues
3511 );
3512 }
3513
3514 #[test]
3516 fn test_invalid_language_code() {
3517 let html = r#"<html lang="invalid-lang"></html>"#;
3518 let document = Html::parse_document(html);
3519 let mut issues = vec![];
3520 AccessibilityReport::check_language_attributes(
3521 &document,
3522 &mut issues,
3523 )
3524 .unwrap();
3525 assert!(issues
3526 .iter()
3527 .any(|i| i.message.contains("Invalid language code")));
3528 }
3529
3530 #[test]
3532 fn test_missing_required_aria_properties() {
3533 let html = r#"<div role="slider"></div>"#;
3534 let fragment = Html::parse_fragment(html);
3535 let element = fragment
3536 .select(&Selector::parse("div").unwrap())
3537 .next()
3538 .unwrap();
3539 let missing =
3540 get_missing_required_aria_properties(&element).unwrap();
3541 assert!(missing.contains(&"aria-valuenow".to_string()));
3542 }
3543
3544 #[test]
3546 #[should_panic(expected = "static regex")]
3547 fn test_invalid_regex_creation_panics() {
3548 let _ = try_create_regex("[unclosed");
3549 }
3550
3551 #[test]
3553 #[should_panic(expected = "static selector")]
3554 fn test_invalid_selector_creation_panics() {
3555 let _ = try_create_selector("div..class");
3556 }
3557
3558 #[test]
3560 fn test_add_aria_empty_buttons() {
3561 let html = r#"<button></button>"#;
3562 let builder = HtmlBuilder::new(html);
3563 let result = add_aria_to_buttons(builder).unwrap().build();
3564 assert!(
3565 result.contains("aria-label"),
3566 "ARIA label should be added to empty button"
3567 );
3568 }
3569
3570 #[test]
3572 fn test_wcag_aaa_validation() {
3573 let html = "<h1>Main Title</h1><h4>Skipped Heading</h4>";
3574 let config = AccessibilityConfig {
3575 wcag_level: WcagLevel::AAA,
3576 ..Default::default()
3577 };
3578 let report = validate_wcag(html, &config, None).unwrap();
3579 assert!(
3580 report.issue_count > 0,
3581 "WCAG AAA should detect issues"
3582 );
3583 }
3584
3585 #[test]
3587 fn test_unique_id_collisions() {
3588 let ids: HashSet<_> =
3589 (0..10_000).map(|_| generate_unique_id()).collect();
3590 assert_eq!(
3591 ids.len(),
3592 10_000,
3593 "Generated IDs should be unique"
3594 );
3595 }
3596
3597 #[test]
3599 fn test_add_aria_navigation() {
3600 let html = "<nav>Main Navigation</nav>";
3601 let builder = HtmlBuilder::new(html);
3602 let result = add_aria_to_navs(builder).unwrap().build();
3603 assert!(
3604 result.contains("aria-label"),
3605 "ARIA label should be added to navigation"
3606 );
3607 }
3608
3609 #[test]
3611 fn test_empty_html_handling() {
3612 let html = "";
3613 let result = add_aria_attributes(html, None);
3614 assert!(
3615 result.is_ok(),
3616 "Empty HTML should not cause errors"
3617 );
3618 assert_eq!(
3619 result.unwrap(),
3620 "",
3621 "Empty HTML should remain unchanged"
3622 );
3623 }
3624
3625 #[test]
3626 fn test_add_aria_to_inputs_with_different_types() {
3627 let html = r#"
3628 <input type="text" placeholder="Username">
3629 <input type="password" placeholder="Password">
3630 <input type="checkbox" id="remember">
3631 <input type="radio" name="choice">
3632 <input type="submit" value="Submit">
3633 <input type="unknown">
3634 "#;
3635
3636 let builder = HtmlBuilder::new(html);
3637 let result = add_aria_to_inputs(builder).unwrap().build();
3638
3639 assert!(!result.contains(r#"type="text".*aria-label"#));
3641 assert!(!result.contains(r#"type="password".*aria-label"#));
3642
3643 assert!(result.contains(
3645 r#"<label for="remember">Checkbox for remember</label>"#
3646 ));
3647
3648 assert!(result
3650 .contains(r#"<label for="option1">Option 1</label>"#));
3651
3652 assert!(!result.contains(r#"type="submit".*aria-label"#));
3654
3655 assert!(result.contains(r#"aria-label="unknown""#));
3657 }
3658
3659 #[test]
3660 fn test_has_associated_label() {
3661 let input = r#"<input type="text" id="username">"#;
3663 let html = r#"<label for="username">Username:</label>"#;
3664 assert!(has_associated_label(input, html));
3665
3666 let input = r#"<input type="text" id="username">"#;
3668 let html = r#"<label for="password">Password:</label>"#;
3669 assert!(!has_associated_label(input, html));
3670
3671 let input = r#"<input type="text">"#;
3673 let html = r#"<label for="username">Username:</label>"#;
3674 assert!(!has_associated_label(input, html));
3675 }
3676
3677 #[test]
3678 fn test_preserve_attributes() {
3679 let input = r#"<input type="text" class="form-control">"#;
3681 let result = preserve_attributes(input);
3682 assert!(result.contains("type=\"text\""));
3683 assert!(result.contains("class=\"form-control\""));
3684
3685 let input = r#"<input type="text">"#;
3687 let result = preserve_attributes(input);
3688 assert!(result.contains("type=\"text\""));
3689
3690 let input = r#"<input type='text'>"#;
3692 let result = preserve_attributes(input);
3693 assert!(result.contains("type='text'"));
3694
3695 let input = r#"<input required>"#;
3697 let result = preserve_attributes(input);
3698 assert!(result.contains("required"));
3699
3700 let input = "<input>";
3702 let result = preserve_attributes(input);
3703 assert!(
3704 result.contains("input"),
3705 "Should preserve the input tag name"
3706 );
3707
3708 let input = r#"<input name="test" value="multiple words">"#;
3710 let result = preserve_attributes(input);
3711 assert!(result.contains("name=\"test\""));
3712 assert!(result.contains("value=\"multiple words\""));
3713 }
3714
3715 #[test]
3716 fn test_preserve_attributes_with_data_attributes() {
3717 let input = r#"<input data-test="value" type="text">"#;
3719 let matches: Vec<String> = ATTRIBUTE_REGEX
3720 .captures_iter(input)
3721 .map(|cap| cap[0].to_string())
3722 .collect();
3723 println!("Actual matches: {:?}", matches);
3724
3725 let result = preserve_attributes(input);
3726 println!("Preserved attributes: {}", result);
3727 }
3728
3729 #[test]
3730 fn test_extract_input_type() {
3731 let input = r#"<input type="text" class="form-control">"#;
3733 assert_eq!(
3734 extract_input_type(input),
3735 Some("text".to_string())
3736 );
3737
3738 let input = r#"<input type='radio' name='choice'>"#;
3740 assert_eq!(
3741 extract_input_type(input),
3742 Some("radio".to_string())
3743 );
3744
3745 let input = r#"<input class="form-control">"#;
3747 assert_eq!(extract_input_type(input), None);
3748
3749 let input = r#"<input type="" class="form-control">"#;
3751 assert_eq!(extract_input_type(input), None); }
3753
3754 #[test]
3755 fn test_add_aria_to_inputs_with_existing_labels() {
3756 let html = r#"
3757 <input type="checkbox" id="existing">
3758 <label for="existing">Existing Label</label>
3759 <input type="radio" id="existing2">
3760 <label for="existing2">Existing Radio</label>
3761 "#;
3762
3763 let builder = HtmlBuilder::new(html);
3764 let result = add_aria_to_inputs(builder).unwrap().build();
3765
3766 assert!(!result.contains("aria-label"));
3768 assert_eq!(
3769 result.matches("<label").count(),
3770 2,
3771 "Should not add additional labels to elements that already have them"
3772 );
3773 }
3774
3775 #[test]
3776 fn test_add_aria_to_inputs_with_special_characters() {
3777 let html = r#"<input type="text" data-test="test's value" class="form & input">"#;
3778 let builder = HtmlBuilder::new(html);
3779 let result = add_aria_to_inputs(builder).unwrap().build();
3780
3781 assert!(result.contains("data-test=\"test's value\""));
3783 assert!(result.contains("class=\"form & input\""));
3784 }
3785
3786 #[test]
3787 fn test_toggle_button() {
3788 let original_html =
3789 r#"<button type="button">Menu</button>"#;
3790 let builder = HtmlBuilder::new(original_html);
3791 let enhanced_html =
3792 add_aria_to_buttons(builder).unwrap().build();
3793
3794 assert_eq!(
3796 enhanced_html,
3797 r#"<button aria-label="menu" type="button">Menu</button>"#,
3798 "The button should be enhanced with an aria-label"
3799 );
3800 }
3801
3802 #[test]
3803 fn test_replace_element_fallback() {
3804 let original = r#"<button disabled>Click</button>"#;
3805 let old_element = r#"<button disabled="">Click</button>"#;
3806 let new_element = r#"<button aria-disabled="true" disabled="">Click</button>"#;
3807
3808 let replaced =
3809 replace_element(original, old_element, new_element);
3810
3811 assert!(replaced.contains(r#"aria-disabled="true""#), "Should replace with fallback even though original has disabled not disabled=\"\"");
3813 }
3814
3815 #[test]
3816 fn test_replace_element_no_match() {
3817 let original = r#"<div>Nothing to replace</div>"#;
3818 let old_element = r#"<button disabled="">Click</button>"#;
3819 let new_element = r#"<button aria-disabled="true" disabled="">Click</button>"#;
3820
3821 let replaced =
3823 replace_element(original, old_element, new_element);
3824 assert_eq!(
3825 replaced, original,
3826 "No match means original stays unchanged"
3827 );
3828 }
3829
3830 #[test]
3831 fn test_normalize_shorthand_attributes_multiple() {
3832 let html = r#"<input disabled selected><button disabled>Press</button>"#;
3833 let normalized = normalize_shorthand_attributes(html);
3834 assert!(
3837 normalized
3838 .contains(r#"<input disabled="" selected="">"#),
3839 "Should expand both disabled and selected"
3840 );
3841 assert!(
3842 normalized.contains(r#"<button disabled="">"#),
3843 "Should expand the disabled attribute on the button"
3844 );
3845 }
3846
3847 #[test]
3848 fn test_remove_invalid_aria_attributes() {
3849 let html = r#"<div aria-hidden="invalid" aria-pressed="true"></div>"#;
3850 let cleaned = remove_invalid_aria_attributes(html);
3853 assert!(
3854 !cleaned.contains(r#"aria-hidden="invalid""#),
3855 "Invalid aria-hidden should be removed"
3856 );
3857 assert!(
3858 cleaned.contains(r#"aria-pressed="true""#),
3859 "Valid attribute should remain"
3860 );
3861 }
3862
3863 #[test]
3864 fn test_is_valid_aria_attribute_cases() {
3865 assert!(
3867 is_valid_aria_attribute("aria-label", "Submit"),
3868 "aria-label with non-empty string is valid"
3869 );
3870
3871 assert!(
3873 is_valid_aria_attribute("aria-pressed", "true"),
3874 "aria-pressed=\"true\" is valid"
3875 );
3876 assert!(
3877 is_valid_aria_attribute("aria-pressed", "false"),
3878 "aria-pressed=\"false\" is valid"
3879 );
3880 assert!(
3881 !is_valid_aria_attribute("aria-pressed", "yes"),
3882 "aria-pressed only allows true/false"
3883 );
3884
3885 assert!(
3887 !is_valid_aria_attribute(
3888 "aria-somethingrandom",
3889 "value"
3890 ),
3891 "Unknown ARIA attribute is invalid"
3892 );
3893 }
3894
3895 #[test]
3896 fn test_add_aria_to_accordions_basic() {
3897 let html = r#"
3898 <div class="accordion">
3899 <button>Section 1</button>
3900 <div>Content 1</div>
3901 <button>Section 2</button>
3902 <div>Content 2</div>
3903 </div>
3904 "#;
3905 let builder = HtmlBuilder::new(html);
3906 let result =
3907 add_aria_to_accordions(builder).unwrap().build();
3908
3909 assert!(
3911 result.contains(r#"aria-controls="section-1-content""#),
3912 "First accordion section should have aria-controls"
3913 );
3914 assert!(
3915 result.contains(r#"id="section-1-button""#),
3916 "First button should get an ID"
3917 );
3918 assert!(
3919 result.contains(r#"id="section-1-content""#),
3920 "First content should get an ID"
3921 );
3922 assert!(
3923 result.contains(r#"hidden"#),
3924 "Accordion content is hidden by default"
3925 );
3926 }
3927
3928 #[test]
3929 fn test_add_aria_to_accordions_empty() {
3930 let html = r#"<div class="accordion"></div>"#;
3931 let builder = HtmlBuilder::new(html);
3932 let result =
3933 add_aria_to_accordions(builder).unwrap().build();
3934
3935 assert!(result.contains(r#"class="accordion""#));
3937 }
3939
3940 #[test]
3941 fn test_add_aria_to_tabs_basic() {
3942 let html = r#"
3944 <div role="tablist">
3945 <button>Tab A</button>
3946 <button>Tab B</button>
3947 </div>
3948 "#;
3949 let builder = HtmlBuilder::new(html);
3950 let result = add_aria_to_tabs(builder).unwrap().build();
3951
3952 assert!(
3954 result.contains(
3955 r#"role="tab" id="tab1" aria-selected="true""#
3956 ),
3957 "First tab should be tab1, selected=true"
3958 );
3959 assert!(
3960 result.contains(
3961 r#"role="tab" id="tab2" aria-selected="false""#
3962 ),
3963 "Second tab should be tab2, selected=false"
3964 );
3965 assert!(
3967 result.contains(r#"aria-controls="panel1""#),
3968 "First tab controls panel1"
3969 );
3970 assert!(
3971 result.contains(r#"id="panel2" role="tabpanel""#),
3972 "Second tab panel should exist"
3973 );
3974 }
3975
3976 #[test]
3978 fn test_add_aria_to_tabs_no_tablist() {
3979 let html = r#"<div><button>Not a tab</button></div>"#;
3980 let builder = HtmlBuilder::new(html);
3981 let result = add_aria_to_tabs(builder).unwrap().build();
3982
3983 assert!(
3985 result.contains(r#"<button>Not a tab</button>"#),
3986 "Should remain unchanged"
3987 );
3988 assert!(!result.contains(r#"role="tab""#), "No transformation to role=tab if not inside role=tablist");
3989 }
3990
3991 #[test]
3993 fn test_count_checked_elements() {
3994 let html = r#"
3995 <html>
3996 <body>
3997 <div>
3998 <p>Paragraph</p>
3999 <span>Span</span>
4000 </div>
4001 </body>
4002 </html>
4003 "#;
4004 let document = Html::parse_document(html);
4005 let count = count_checked_elements(&document);
4006 assert!(
4010 count >= 5,
4011 "Expected at least 5 elements in the parsed tree"
4012 );
4013 }
4014
4015 #[test]
4016 fn test_check_language_attributes_valid() {
4017 let html = r#"<html lang="en"><body></body></html>"#;
4018 let document = Html::parse_document(html);
4019 let mut issues = vec![];
4020 let result = AccessibilityReport::check_language_attributes(
4021 &document,
4022 &mut issues,
4023 );
4024 assert!(result.is_ok());
4025 assert_eq!(issues.len(), 0, "No issues for valid lang");
4026 }
4027
4028 #[test]
4029 fn test_error_variants() {
4030 let _ = Error::InvalidAriaAttribute {
4031 attribute: "aria-bogus".to_string(),
4032 message: "Bogus attribute".to_string(),
4033 };
4034 let _ = Error::WcagValidationError {
4035 level: WcagLevel::AA,
4036 message: "Validation failed".to_string(),
4037 guideline: Some("WCAG 2.4.6".to_string()),
4038 };
4039 let _ = Error::HtmlTooLarge {
4040 size: 9999999,
4041 max_size: 1000000,
4042 };
4043 let _ = Error::HtmlProcessingError {
4044 message: "Something went wrong".to_string(),
4045 source: None,
4046 };
4047 let _ = Error::MalformedHtml {
4048 message: "Broken HTML".to_string(),
4049 fragment: None,
4050 };
4051 }
4052
4053 #[test]
4054 fn test_has_associated_label_no_id() {
4055 let input = r#"<input type="checkbox">"#;
4056 let html =
4057 r#"<label for="checkbox1">Checkbox Label</label>"#;
4058 assert!(
4060 !has_associated_label(input, html),
4061 "No ID => false"
4062 );
4063 }
4064
4065 #[test]
4066 fn test_generate_unique_id_format() {
4067 let new_id = generate_unique_id();
4068 assert!(
4070 new_id.starts_with("aria-"),
4071 "Generated ID should start with aria-"
4072 );
4073 }
4074
4075 #[test]
4076 fn test_add_aria_to_buttons_basic_button() {
4077 let html = r#"<button>Click me</button>"#;
4078 let builder = HtmlBuilder::new(html);
4079 let result = add_aria_to_buttons(builder).unwrap().build();
4080
4081 assert!(
4084 result.contains(r#"aria-label="click-me""#),
4085 "Should add aria-label for normal button text"
4086 );
4087 assert!(
4088 !result.contains(r#"aria-pressed=""#),
4089 "Should not add aria-pressed if not originally present"
4090 );
4091 }
4092
4093 #[test]
4094 fn test_add_aria_to_buttons_disabled() {
4095 let html = r#"<button disabled>Submit</button>"#;
4096 let builder = HtmlBuilder::new(html);
4097 let result = add_aria_to_buttons(builder).unwrap().build();
4098 assert!(
4101 result.contains(r#"aria-disabled="true""#),
4102 "Disabled button should have aria-disabled"
4103 );
4104 assert!(
4105 result.contains(r#"aria-label="submit""#),
4106 "Should have aria-label from button text"
4107 );
4108 assert!(
4110 !result.contains("aria-pressed"),
4111 "Disabled button shouldn't have aria-pressed"
4112 );
4113 }
4114
4115 #[test]
4116 fn test_add_aria_to_buttons_icon_span() {
4117 let html = r#"<button><span class="icon">🔍</span>Search</button>"#;
4118 let builder = HtmlBuilder::new(html);
4119 let result = add_aria_to_buttons(builder).unwrap().build();
4120
4121 assert!(
4124 result.contains(r#"left-pointing-magnifying-glass""#)
4125 );
4126 assert!(
4127 result.contains(
4128 r#"<span class="icon" aria-hidden="true">🔍</span>"#
4129 ),
4130 "Icon span should have aria-hidden=\"true\""
4131 );
4132 }
4133
4134 #[test]
4135 fn test_add_aria_to_buttons_toggle_flip() {
4136 let html = r#"<button aria-pressed="true">Toggle</button>"#;
4138 let builder = HtmlBuilder::new(html);
4139 let result = add_aria_to_buttons(builder).unwrap().build();
4140
4141 assert!(
4143 result.contains(r#"aria-pressed="false""#),
4144 "Existing aria-pressed=\"true\" should flip to false"
4145 );
4146 assert!(result.contains(r#"aria-label="toggle""#));
4148 }
4149
4150 #[test]
4151 fn test_add_aria_to_buttons_toggle_no_flip() {
4152 let html = r#"<button aria-pressed="true">On</button>"#;
4156 let builder = HtmlBuilder::new(html);
4161 let result = add_aria_to_buttons(builder).unwrap().build();
4162 assert!(result.contains(r#"aria-pressed="false""#));
4167 }
4168
4169 #[test]
4173 fn test_add_aria_to_toggle_no_aria_pressed() {
4174 let html = r#"<div class="toggle-button">Click me</div>"#;
4176 let builder = HtmlBuilder::new(html);
4177 let result = add_aria_to_toggle(builder).unwrap().build();
4178
4179 let doc = Html::parse_document(&result);
4181 let selector =
4182 Selector::parse("button.toggle-button").unwrap();
4183 let toggle = doc
4184 .select(&selector)
4185 .next()
4186 .expect("Should have button.toggle-button");
4187 assert_eq!(
4188 toggle.value().attr("aria-pressed"),
4189 Some("false")
4190 );
4191 assert_eq!(toggle.value().attr("role"), Some("button"));
4192 assert_eq!(toggle.inner_html().trim(), "Click me");
4193 }
4194
4195 #[test]
4196 fn test_add_aria_to_toggle_existing_aria_pressed() {
4197 let html = r#"<div class="toggle-button" aria-pressed="true">I'm on</div>"#;
4199 let builder = HtmlBuilder::new(html);
4200 let result = add_aria_to_toggle(builder).unwrap().build();
4201
4202 assert!(
4204 result.contains("toggle-button"),
4205 "Should preserve the toggle-button class"
4206 );
4207 assert!(
4208 result.contains("I'm on"),
4209 "Should preserve the content"
4210 );
4211 assert!(
4212 result.contains(r#"aria-pressed="true""#),
4213 "Should preserve aria-pressed value"
4214 );
4215 }
4216
4217 #[test]
4218 fn test_add_aria_to_toggle_preserves_other_attrs() {
4219 let html = r#"<div class="toggle-button" data-role="switch" style="color:red;" aria-pressed="false">Toggle</div>"#;
4220 let builder = HtmlBuilder::new(html);
4221 let result = add_aria_to_toggle(builder).unwrap().build();
4222
4223 assert!(
4225 result.contains(r#"class="toggle-button""#),
4226 "Should preserve class"
4227 );
4228 assert!(
4229 result.contains(r#"data-role="switch""#),
4230 "Should preserve data attribute"
4231 );
4232 assert!(
4233 result.contains(r#"style="color:red;""#),
4234 "Should preserve style"
4235 );
4236 assert!(
4237 result.contains(r#"aria-pressed="false""#),
4238 "Should preserve aria-pressed"
4239 );
4240 }
4241
4242 #[test]
4243 fn test_add_aria_to_toggle_no_toggle_elements() {
4244 let html = r#"<div>Just a regular div</div>"#;
4245 let builder = HtmlBuilder::new(html);
4246 let result = add_aria_to_toggle(builder).unwrap().build();
4247 assert_eq!(
4249 result, html,
4250 "No transformation if there's no .toggle-button"
4251 );
4252 }
4253
4254 #[test]
4255 fn test_has_alert_class_sets_alertdialog() -> Result<()> {
4256 let original_html = r#"
4258 <div class="modal alert">
4259 <div class="modal-content"><h2>Warning</h2><button>OK</button></div>
4260 </div>
4261 "#;
4262 let builder = HtmlBuilder {
4263 content: original_html.to_string(),
4264 };
4265
4266 let result = add_aria_to_modals(builder)?;
4267 let output = result.content;
4268
4269 assert!(
4271 output.contains(r#"role="alertdialog""#),
4272 "Expected role=\"alertdialog\" for .alert class"
4273 );
4274 assert!(
4275 output.contains(r#"aria-modal="true""#),
4276 "Expected aria-modal=\"true\" to be set"
4277 );
4278 Ok(())
4279 }
4280
4281 #[test]
4282 fn test_preserves_role_dialog() -> Result<()> {
4283 let original_html = r#"
4285 <div class="modal" role="dialog">
4286 <div class="modal-content"><button>Close</button></div>
4287 </div>
4288 "#;
4289 let builder = HtmlBuilder {
4290 content: original_html.to_string(),
4291 };
4292
4293 let result = add_aria_to_modals(builder)?;
4294 let output = result.content;
4295
4296 assert!(
4298 output.contains(r#"role="dialog""#),
4299 "Should preserve role=\"dialog\""
4300 );
4301 assert!(
4303 output.contains(r#"aria-modal="true""#),
4304 "Expected aria-modal=\"true\" to be added"
4305 );
4306 Ok(())
4307 }
4308
4309 #[test]
4310 fn test_preserves_role_alertdialog() -> Result<()> {
4311 let original_html = r#"
4313 <div class="modal" role="alertdialog">
4314 <div class="modal-content"><h2>Warning</h2></div>
4315 </div>
4316 "#;
4317 let builder = HtmlBuilder {
4318 content: original_html.to_string(),
4319 };
4320
4321 let result = add_aria_to_modals(builder)?;
4322 let output = result.content;
4323
4324 assert!(
4326 output.contains(r#"role="alertdialog""#),
4327 "Should preserve role=\"alertdialog\""
4328 );
4329 assert!(
4331 output.contains(r#"aria-modal="true""#),
4332 "Expected aria-modal=\"true\" to be added"
4333 );
4334 Ok(())
4335 }
4336
4337 #[test]
4338 fn test_already_has_aria_modal_does_not_duplicate() -> Result<()>
4339 {
4340 let original_html = r#"
4342 <div class="modal" role="dialog" aria-modal="true">
4343 <div class="modal-content"><button>Close</button></div>
4344 </div>
4345 "#;
4346 let builder = HtmlBuilder {
4347 content: original_html.to_string(),
4348 };
4349
4350 let result = add_aria_to_modals(builder)?;
4351 let output = result.content;
4352
4353 let count = output.matches(r#"aria-modal="true""#).count();
4356 assert_eq!(
4357 count, 1,
4358 "aria-modal=\"true\" should only appear once"
4359 );
4360 Ok(())
4361 }
4362
4363 #[test]
4364 fn test_adds_aria_describedby_for_dialog_description(
4365 ) -> Result<()> {
4366 let original_html = r#"
4368 <div class="modal">
4369 <div class="dialog-description">This is an important message</div>
4370 <div class="modal-content"><button>Close</button></div>
4371 </div>
4372 "#;
4373 let builder = HtmlBuilder {
4374 content: original_html.to_string(),
4375 };
4376
4377 let result = add_aria_to_modals(builder)?;
4378 let output = result.content;
4379
4380 assert!(
4382 output.contains(r#"role="dialog""#),
4383 "Expected role=\"dialog\""
4384 );
4385 assert!(
4386 output.contains(r#"aria-modal="true""#),
4387 "Expected aria-modal=\"true\""
4388 );
4389
4390 let has_aria_describedby =
4393 output.contains("aria-describedby=");
4394 assert!(
4395 has_aria_describedby,
4396 "Should have aria-describedby referencing the .dialog-description"
4397 );
4398 Ok(())
4399 }
4400
4401 #[test]
4402 fn test_dialog_description_missing_does_not_add_aria_describedby(
4403 ) -> Result<()> {
4404 let original_html = r#"
4406 <div class="modal">
4407 <div class="modal-content"><button>Close</button></div>
4408 </div>
4409 "#;
4410
4411 let builder = HtmlBuilder {
4412 content: original_html.to_string(),
4413 };
4414
4415 let result = add_aria_to_modals(builder)?;
4416 let output = result.content;
4417
4418 assert!(
4420 !output.contains("aria-describedby="),
4421 "Should not add aria-describedby if no descriptive element is found"
4422 );
4423 Ok(())
4424 }
4425
4426 #[test]
4427 fn test_paragraph_as_dialog_description() -> Result<()> {
4428 let original_html = r#"
4430 <div class="modal">
4431 <p>This is a brief description of the dialog.</p>
4432 <div class="modal-content"><button>Close</button></div>
4433 </div>
4434 "#;
4435
4436 let builder = HtmlBuilder {
4437 content: original_html.to_string(),
4438 };
4439
4440 let result = add_aria_to_modals(builder)?;
4441 let output = result.content;
4442
4443 assert!(
4447 output.contains("aria-describedby="),
4448 "Should have aria-describedby referencing the <p>"
4449 );
4450 assert!(
4451 output.contains("id=\"dialog-desc-"),
4452 "Should have auto-generated ID assigned to <p>"
4453 );
4454 Ok(())
4455 }
4456 }
4457}