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 {
65 pub const MAX_HTML_SIZE: usize = 1_000_000;
67
68 pub const DEFAULT_NAV_ROLE: &str = "navigation";
70
71 pub const DEFAULT_BUTTON_ROLE: &str = "button";
73
74 pub const DEFAULT_FORM_ROLE: &str = "form";
76
77 pub const DEFAULT_INPUT_ROLE: &str = "textbox";
79}
80
81use constants::{DEFAULT_BUTTON_ROLE, DEFAULT_NAV_ROLE, MAX_HTML_SIZE};
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum WcagLevel {
88 A,
91
92 AA,
95
96 AAA,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum IssueType {
104 MissingAltText,
106 HeadingStructure,
108 MissingLabels,
110 InvalidAria,
112 ColorContrast,
114 KeyboardNavigation,
116 LanguageDeclaration,
118}
119
120#[derive(Debug, Error)]
122pub enum Error {
123 #[error("Invalid ARIA Attribute '{attribute}': {message}")]
125 InvalidAriaAttribute {
126 attribute: String,
128 message: String,
130 },
131
132 #[error("WCAG {level} Validation Error: {message}")]
134 WcagValidationError {
135 level: WcagLevel,
137 message: String,
139 guideline: Option<String>,
141 },
142
143 #[error(
145 "HTML Input Too Large: size {size} exceeds maximum {max_size}"
146 )]
147 HtmlTooLarge {
148 size: usize,
150 max_size: usize,
152 },
153
154 #[error("HTML Processing Error: {message}")]
156 HtmlProcessingError {
157 message: String,
159 source: Option<Box<dyn std::error::Error + Send + Sync>>,
161 },
162
163 #[error("Malformed HTML: {message}")]
165 MalformedHtml {
166 message: String,
168 fragment: Option<String>,
170 },
171}
172
173pub type Result<T> = std::result::Result<T, Error>;
175
176#[derive(Debug, Clone)]
178pub struct Issue {
179 pub issue_type: IssueType,
181 pub message: String,
183 pub guideline: Option<String>,
185 pub element: Option<String>,
187 pub suggestion: Option<String>,
189}
190
191fn try_create_selector(selector: &str) -> Option<Selector> {
193 match Selector::parse(selector) {
194 Ok(s) => Some(s),
195 Err(e) => {
196 eprintln!(
197 "Failed to create selector '{}': {}",
198 selector, e
199 );
200 None
201 }
202 }
203}
204
205fn try_create_regex(pattern: &str) -> Option<Regex> {
207 match Regex::new(pattern) {
208 Ok(r) => Some(r),
209 Err(e) => {
210 eprintln!("Failed to create regex '{}': {}", pattern, e);
211 None
212 }
213 }
214}
215
216static BUTTON_SELECTOR: Lazy<Option<Selector>> =
218 Lazy::new(|| try_create_selector("button:not([aria-label])"));
219
220static NAV_SELECTOR: Lazy<Option<Selector>> =
222 Lazy::new(|| try_create_selector("nav:not([aria-label])"));
223
224static FORM_SELECTOR: Lazy<Option<Selector>> =
226 Lazy::new(|| try_create_selector("form:not([aria-labelledby])"));
227
228static INPUT_REGEX: Lazy<Option<Regex>> =
230 Lazy::new(|| try_create_regex(r"<input[^>]*>"));
231
232static ARIA_SELECTOR: Lazy<Option<Selector>> = Lazy::new(|| {
234 try_create_selector(concat!(
235 "[aria-label], [aria-labelledby], [aria-describedby], ",
236 "[aria-hidden], [aria-expanded], [aria-haspopup], ",
237 "[aria-controls], [aria-pressed], [aria-checked], ",
238 "[aria-current], [aria-disabled], [aria-dropeffect], ",
239 "[aria-grabbed], [aria-invalid], [aria-live], ",
240 "[aria-owns], [aria-relevant], [aria-required], ",
241 "[aria-role], [aria-selected], [aria-valuemax], ",
242 "[aria-valuemin], [aria-valuenow], [aria-valuetext]"
243 ))
244});
245
246static VALID_ARIA_ATTRIBUTES: Lazy<HashSet<&'static str>> =
248 Lazy::new(|| {
249 [
250 "aria-label",
251 "aria-labelledby",
252 "aria-describedby",
253 "aria-hidden",
254 "aria-expanded",
255 "aria-haspopup",
256 "aria-controls",
257 "aria-pressed",
258 "aria-checked",
259 "aria-current",
260 "aria-disabled",
261 "aria-dropeffect",
262 "aria-grabbed",
263 "aria-invalid",
264 "aria-live",
265 "aria-owns",
266 "aria-relevant",
267 "aria-required",
268 "aria-role",
269 "aria-selected",
270 "aria-valuemax",
271 "aria-valuemin",
272 "aria-valuenow",
273 "aria-valuetext",
274 ]
275 .iter()
276 .copied()
277 .collect()
278 });
279
280#[derive(Debug, Copy, Clone)]
309pub struct AccessibilityConfig {
310 pub wcag_level: WcagLevel,
312 pub max_heading_jump: u8,
314 pub min_contrast_ratio: f64,
316 pub auto_fix: bool,
318}
319
320impl Default for AccessibilityConfig {
321 fn default() -> Self {
322 Self {
323 wcag_level: WcagLevel::AA,
324 max_heading_jump: 1,
325 min_contrast_ratio: 4.5, auto_fix: true,
327 }
328 }
329}
330
331#[derive(Debug, Clone)]
333pub struct AccessibilityReport {
334 pub issues: Vec<Issue>,
336 pub wcag_level: WcagLevel,
338 pub elements_checked: usize,
340 pub issue_count: usize,
342 pub check_duration_ms: u64,
344}
345
346pub fn add_aria_attributes(
372 html: &str,
373 config: Option<AccessibilityConfig>,
374) -> Result<String> {
375 let config = config.unwrap_or_default();
376
377 if html.len() > MAX_HTML_SIZE {
378 return Err(Error::HtmlTooLarge {
379 size: html.len(),
380 max_size: MAX_HTML_SIZE,
381 });
382 }
383
384 let mut html_builder = HtmlBuilder::new(html);
385
386 html_builder = add_aria_to_accordions(html_builder)?;
388 html_builder = add_aria_to_modals(html_builder)?;
389 html_builder = add_aria_to_buttons(html_builder)?;
390 html_builder = add_aria_to_forms(html_builder)?;
391 html_builder = add_aria_to_inputs(html_builder)?;
392 html_builder = add_aria_to_navs(html_builder)?;
393 html_builder = add_aria_to_tabs(html_builder)?;
394 html_builder = add_aria_to_toggle(html_builder)?;
395 html_builder = add_aria_to_tooltips(html_builder)?;
396
397 if matches!(config.wcag_level, WcagLevel::AA | WcagLevel::AAA) {
399 html_builder = enhance_landmarks(html_builder)?;
400 html_builder = add_live_regions(html_builder)?;
401 }
402
403 if matches!(config.wcag_level, WcagLevel::AAA) {
404 html_builder = enhance_descriptions(html_builder)?;
405 }
406
407 let new_html =
409 remove_invalid_aria_attributes(&html_builder.build());
410
411 if !validate_aria(&new_html) {
412 return Err(Error::InvalidAriaAttribute {
413 attribute: "multiple".to_string(),
414 message: "Failed to add valid ARIA attributes".to_string(),
415 });
416 }
417
418 Ok(new_html)
419}
420
421#[derive(Debug, Clone)]
423struct HtmlBuilder {
424 content: String,
425}
426
427impl HtmlBuilder {
428 fn new(initial_content: &str) -> Self {
430 HtmlBuilder {
431 content: initial_content.to_string(),
432 }
433 }
434
435 fn build(self) -> String {
437 self.content
438 }
439}
440
441fn count_checked_elements(document: &Html) -> usize {
443 document.select(&Selector::parse("*").unwrap()).count()
444}
445
446const fn enhance_landmarks(
448 html_builder: HtmlBuilder,
449) -> Result<HtmlBuilder> {
450 Ok(html_builder)
452}
453
454const fn add_live_regions(
456 html_builder: HtmlBuilder,
457) -> Result<HtmlBuilder> {
458 Ok(html_builder)
460}
461
462const fn enhance_descriptions(
464 html_builder: HtmlBuilder,
465) -> Result<HtmlBuilder> {
466 Ok(html_builder)
468}
469
470fn check_heading_structure(document: &Html, issues: &mut Vec<Issue>) {
472 let mut prev_level: Option<u8> = None;
473
474 let selector = match Selector::parse("h1, h2, h3, h4, h5, h6") {
475 Ok(selector) => selector,
476 Err(e) => {
477 eprintln!("Failed to parse selector: {}", e);
478 return; }
480 };
481
482 for heading in document.select(&selector) {
483 let current_level = heading
484 .value()
485 .name()
486 .chars()
487 .nth(1)
488 .and_then(|c| c.to_digit(10))
489 .and_then(|n| u8::try_from(n).ok());
490
491 if let Some(current_level) = current_level {
492 if let Some(prev_level) = prev_level {
493 if current_level > prev_level + 1 {
494 issues.push(Issue {
495 issue_type: IssueType::HeadingStructure,
496 message: format!(
497 "Skipped heading level from h{} to h{}",
498 prev_level, current_level
499 ),
500 guideline: Some("WCAG 2.4.6".to_string()),
501 element: Some(heading.html()),
502 suggestion: Some(
503 "Use sequential heading levels".to_string(),
504 ),
505 });
506 }
507 }
508 prev_level = Some(current_level);
509 }
510 }
511}
512
513pub fn validate_wcag(
543 html: &str,
544 config: &AccessibilityConfig,
545 disable_checks: Option<&[IssueType]>,
546) -> Result<AccessibilityReport> {
547 let start_time = std::time::Instant::now();
548 let mut issues = Vec::new();
549 let mut elements_checked = 0;
550
551 if html.trim().is_empty() {
552 return Ok(AccessibilityReport {
553 issues: Vec::new(),
554 wcag_level: config.wcag_level,
555 elements_checked: 0,
556 issue_count: 0,
557 check_duration_ms: 0,
558 });
559 }
560
561 let document = Html::parse_document(html);
562
563 if disable_checks
564 .map_or(true, |d| !d.contains(&IssueType::LanguageDeclaration))
565 {
566 check_language_attributes(&document, &mut issues)?; }
568
569 check_heading_structure(&document, &mut issues);
571
572 elements_checked += count_checked_elements(&document);
573
574 let check_duration_ms = u64::try_from(
576 start_time.elapsed().as_millis(),
577 )
578 .map_err(|err| Error::HtmlProcessingError {
579 message: "Failed to convert duration to milliseconds"
580 .to_string(),
581 source: Some(Box::new(err)),
582 })?;
583
584 Ok(AccessibilityReport {
585 issues: issues.clone(),
586 wcag_level: config.wcag_level,
587 elements_checked,
588 issue_count: issues.len(),
589 check_duration_ms,
590 })
591}
592
593impl From<std::num::TryFromIntError> for Error {
595 fn from(err: std::num::TryFromIntError) -> Self {
596 Error::HtmlProcessingError {
597 message: "Integer conversion error".to_string(),
598 source: Some(Box::new(err)),
599 }
600 }
601}
602
603impl std::fmt::Display for WcagLevel {
605 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606 match self {
607 WcagLevel::A => write!(f, "A"),
608 WcagLevel::AA => write!(f, "AA"),
609 WcagLevel::AAA => write!(f, "AAA"),
610 }
611 }
612}
613
614impl AccessibilityReport {
616 fn add_issue(
618 issues: &mut Vec<Issue>,
619 issue_type: IssueType,
620 message: impl Into<String>,
621 guideline: Option<String>,
622 element: Option<String>,
623 suggestion: Option<String>,
624 ) {
625 issues.push(Issue {
626 issue_type,
627 message: message.into(),
628 guideline,
629 element,
630 suggestion,
631 });
632 }
633}
634
635static HTML_TAG_REGEX: Lazy<Regex> = Lazy::new(|| {
637 Regex::new(r"<[^>]*>").expect("Failed to compile HTML tag regex")
638});
639
640static EMOJI_MAP: Lazy<
642 std::result::Result<HashMap<String, String>, std::io::Error>,
643> = Lazy::new(|| load_emoji_sequences("data/emoji-data.txt"));
644
645fn normalize_aria_label(content: &str) -> String {
655 let no_html = HTML_TAG_REGEX.replace_all(content, "");
657 let text_only = no_html.trim();
659
660 if text_only.is_empty() {
662 return DEFAULT_BUTTON_ROLE.to_string();
663 }
664
665 match &*EMOJI_MAP {
668 Ok(map) => {
669 for (emoji, label) in map.iter() {
670 if text_only.contains(emoji) {
671 return label.clone();
672 }
673 }
674 }
675 Err(e) => {
676 eprintln!("Error loading emoji sequences: {}", e);
678 }
679 }
680
681 text_only
683 .to_lowercase()
684 .replace(|c: char| !c.is_alphanumeric(), "-")
685 .replace("--", "-")
686 .trim_matches('-')
687 .to_string()
688}
689
690fn add_aria_to_tooltips(
699 mut html_builder: HtmlBuilder,
700) -> Result<HtmlBuilder> {
701 let document = Html::parse_document(&html_builder.content);
702
703 let mut tooltip_counter = 0;
705
706 let button_selector = Selector::parse("button").unwrap();
708 let buttons: Vec<ElementRef> =
709 document.select(&button_selector).collect();
710
711 for button in buttons {
712 let old_button_html = button
714 .html()
715 .replace('\n', "")
716 .replace('\r', "")
717 .trim()
718 .to_string();
719
720 let title_attr =
722 button.value().attr("title").unwrap_or("").trim();
723 if title_attr.is_empty() {
724 continue;
725 }
726
727 tooltip_counter += 1;
729 let tooltip_id = format!("tooltip-{}", tooltip_counter);
730
731 let mut new_button_attrs = Vec::new();
733 let button_inner = button.inner_html();
734
735 for (key, val) in button.value().attrs() {
737 if key != "aria-describedby" {
738 new_button_attrs.push(format!(r#"{}="{}""#, key, val));
739 }
740 }
741
742 new_button_attrs
744 .push(format!(r#"aria-describedby="{}""#, tooltip_id));
745
746 let new_button_snippet = format!(
751 r#"<button {}>{}</button><span id="{}" role="tooltip" hidden>{}</span>"#,
752 new_button_attrs.join(" "),
753 button_inner,
754 tooltip_id,
755 title_attr
756 );
757
758 html_builder.content = replace_html_element_resilient(
760 &html_builder.content,
761 &old_button_html,
762 &new_button_snippet,
763 );
764 }
765
766 Ok(html_builder)
767}
768
769fn add_aria_to_toggle(
788 mut html_builder: HtmlBuilder,
789) -> Result<HtmlBuilder> {
790 let document = Html::parse_document(&html_builder.content);
792
793 if let Ok(selector) = Selector::parse(".toggle-button") {
796 for toggle_elem in document.select(&selector) {
797 let old_html = toggle_elem.html();
798 let content = toggle_elem.inner_html();
799
800 let mut attributes = Vec::new();
802
803 let old_aria_pressed = toggle_elem
806 .value()
807 .attr("aria-pressed")
808 .unwrap_or("false");
809 attributes.push(format!(
811 r#"aria-pressed="{}""#,
812 old_aria_pressed
813 ));
814
815 attributes.push(r#"role="button""#.to_string());
817
818 for (key, value) in toggle_elem.value().attrs() {
820 if key != "aria-pressed" {
821 attributes.push(format!(r#"{}="{}""#, key, value));
822 }
823 }
824
825 let new_html = format!(
827 r#"<button {}>{}</button>"#,
828 attributes.join(" "),
829 content
830 );
831
832 html_builder.content = replace_html_element_resilient(
834 &html_builder.content,
835 &old_html,
836 &new_html,
837 );
838 }
839 }
840
841 Ok(html_builder)
842}
843
844fn add_aria_to_buttons(
859 mut html_builder: HtmlBuilder,
860) -> Result<HtmlBuilder> {
861 let document = Html::parse_document(&html_builder.content);
862
863 if let Some(selector) = BUTTON_SELECTOR.as_ref() {
865 for button in document.select(selector) {
866 let original_button_html = button.html();
867 let mut inner_content = button.inner_html();
868 let mut aria_label = normalize_aria_label(&inner_content);
869
870 if inner_content.contains(r#"<span class="icon">"#) {
874 let replacement =
875 r#"<span class="icon" aria-hidden="true">"#;
876 inner_content = inner_content
877 .replace(r#"<span class="icon">"#, replacement);
878 }
879
880 let mut attributes = Vec::new();
882
883 if button.value().attr("disabled").is_some() {
885 eprintln!(
886 "Processing disabled button: {}",
887 original_button_html
888 );
889 attributes.push(r#"aria-disabled="true""#.to_string());
890 } else {
891 if let Some(current_state) =
893 button.value().attr("aria-pressed")
894 {
895 let new_state = if current_state == "true" {
898 "false"
899 } else {
900 "true"
901 };
902 attributes.push(format!(
903 r#"aria-pressed="{}""#,
904 new_state
905 ));
906 }
907 }
909
910 if aria_label.is_empty() {
912 aria_label = "button".to_string();
913 }
914 attributes.push(format!(r#"aria-label="{}""#, aria_label));
915
916 for (key, value) in button.value().attrs() {
918 if key == "aria-pressed" {
920 continue;
921 }
922 attributes.push(format!(r#"{}="{}""#, key, value));
923 }
924
925 let new_button_html = format!(
927 "<button {}>{}</button>",
928 attributes.join(" "),
929 inner_content
930 );
931
932 html_builder.content = replace_html_element_resilient(
934 &html_builder.content,
935 &original_button_html,
936 &new_button_html,
937 );
938 }
939 }
940
941 Ok(html_builder)
942}
943
944fn replace_html_element_resilient(
946 original_html: &str,
947 old_element: &str,
948 new_element: &str,
949) -> String {
950 let normalized_original =
952 normalize_shorthand_attributes(original_html);
953 let normalized_old = normalize_shorthand_attributes(old_element);
954
955 let replaced_normalized =
957 normalized_original.replacen(&normalized_old, new_element, 1);
958 if replaced_normalized != normalized_original {
959 return replaced_normalized;
960 }
961
962 let shorthand_old =
964 old_element.replace(r#"disabled=""#, "disabled");
965
966 let replaced_shorthand =
967 original_html.replacen(&shorthand_old, new_element, 1);
968 if replaced_shorthand != original_html {
969 return replaced_shorthand;
970 }
971
972 original_html.replacen(old_element, new_element, 1)
974}
975
976fn normalize_shorthand_attributes(html: &str) -> String {
977 let re = Regex::new(
978 r"\b(disabled|checked|readonly|multiple|selected|autofocus|required)([\s>])"
979).unwrap();
980
981 re.replace_all(html, |caps: ®ex::Captures| {
982 let attr = &caps[1]; let delim = &caps[2]; format!(r#"{}=""{}"#, attr, delim)
989 })
990 .to_string()
991}
992
993fn add_aria_to_navs(
995 mut html_builder: HtmlBuilder,
996) -> Result<HtmlBuilder> {
997 let document = Html::parse_document(&html_builder.content);
998
999 if let Some(selector) = NAV_SELECTOR.as_ref() {
1000 for nav in document.select(selector) {
1001 let nav_html = nav.html();
1002 let new_nav_html = nav_html.replace(
1003 "<nav",
1004 &format!(
1005 r#"<nav aria-label="{}" role="navigation""#,
1006 DEFAULT_NAV_ROLE
1007 ),
1008 );
1009 html_builder.content =
1010 html_builder.content.replace(&nav_html, &new_nav_html);
1011 }
1012 }
1013
1014 Ok(html_builder)
1015}
1016
1017fn add_aria_to_forms(
1019 mut html_builder: HtmlBuilder,
1020) -> Result<HtmlBuilder> {
1021 let document = Html::parse_document(&html_builder.content);
1022
1023 let forms = document.select(FORM_SELECTOR.as_ref().unwrap());
1025 for form in forms {
1026 let form_id = format!("form-{}", generate_unique_id());
1028
1029 let form_element = form.value().clone();
1030 let mut attributes = form_element.attrs().collect::<Vec<_>>();
1031
1032 if !attributes.iter().any(|&(k, _)| k == "id") {
1034 attributes.push(("id", &*form_id));
1035 }
1036
1037 if !attributes.iter().any(|&(k, _)| k == "aria-labelledby") {
1039 attributes.push(("aria-labelledby", &*form_id));
1040 }
1041
1042 let new_form_html = format!(
1044 "<form {}>{}</form>",
1045 attributes
1046 .iter()
1047 .map(|&(k, v)| format!(r#"{}="{}""#, k, v))
1048 .collect::<Vec<_>>()
1049 .join(" "),
1050 form.inner_html()
1051 );
1052
1053 html_builder.content =
1054 html_builder.content.replace(&form.html(), &new_form_html);
1055 }
1056
1057 Ok(html_builder)
1058}
1059
1060fn add_aria_to_tabs(
1071 mut html_builder: HtmlBuilder,
1072) -> Result<HtmlBuilder> {
1073 let document = Html::parse_document(&html_builder.content);
1074
1075 if let Ok(tablist_selector) = Selector::parse("[role='tablist']") {
1077 for tablist in document.select(&tablist_selector) {
1078 let tablist_html = tablist.html();
1079
1080 let mut new_html = String::new();
1082 new_html.push_str("<div role=\"tablist\">");
1083
1084 let mut button_texts = Vec::new();
1086 if let Ok(button_selector) = Selector::parse("button") {
1087 for button in tablist.select(&button_selector) {
1088 button_texts.push(button.inner_html());
1090 }
1091 }
1092
1093 for (i, text) in button_texts.iter().enumerate() {
1095 let is_selected = i == 0;
1096 let num = i + 1;
1097 new_html.push_str(&format!(
1098 r#"<button role="tab" id="tab{}" aria-selected="{}" aria-controls="panel{}" tabindex="{}">{}</button>"#,
1099 num,
1100 is_selected,
1101 num,
1102 if is_selected { "0" } else { "-1" },
1103 text
1104 ));
1105 }
1106 new_html.push_str("</div>");
1108
1109 for i in 0..button_texts.len() {
1111 let num = i + 1;
1112 let maybe_hidden = if i == 0 { "" } else { "hidden" };
1114 new_html.push_str(&format!(
1115 r#"<div id="panel{}" role="tabpanel" aria-labelledby="tab{}"{}>Panel {}</div>"#,
1116 num,
1117 num,
1118 maybe_hidden,
1119 num
1120 ));
1121 }
1122
1123 html_builder.content =
1125 html_builder.content.replace(&tablist_html, &new_html);
1126 }
1127 }
1128
1129 Ok(html_builder)
1130}
1131
1132fn add_aria_to_modals(
1140 mut html_builder: HtmlBuilder,
1141) -> Result<HtmlBuilder> {
1142 let document = Html::parse_fragment(&html_builder.content);
1144
1145 let modal_selector = match Selector::parse(".modal") {
1147 Ok(s) => s,
1148 Err(e) => {
1149 eprintln!("Failed to parse .modal selector: {}", e);
1150 return Ok(html_builder); }
1152 };
1153
1154 for modal_elem in document.select(&modal_selector) {
1156 let old_modal_html = modal_elem.html();
1157
1158 let mut new_attrs = Vec::new();
1160 let mut found_role = false;
1161 let mut found_aria_modal = false;
1162 let mut existing_role_value = String::new();
1163
1164 for (attr_name, attr_value) in modal_elem.value().attrs() {
1166 if attr_name.eq_ignore_ascii_case("role") {
1167 found_role = true;
1168 existing_role_value = attr_value.to_string();
1169 new_attrs
1170 .push(format!(r#"{}="{}""#, attr_name, attr_value));
1171 } else if attr_name.eq_ignore_ascii_case("aria-modal") {
1172 found_aria_modal = true;
1173 new_attrs
1174 .push(format!(r#"{}="{}""#, attr_name, attr_value));
1175 } else {
1176 new_attrs
1178 .push(format!(r#"{}="{}""#, attr_name, attr_value));
1179 }
1180 }
1181
1182 let is_alert_dialog = existing_role_value
1184 .eq_ignore_ascii_case("alertdialog")
1185 || modal_elem.value().has_class(
1186 "alert",
1187 CaseSensitivity::AsciiCaseInsensitive,
1188 );
1189
1190 if !found_role || existing_role_value.trim().is_empty() {
1192 if is_alert_dialog {
1193 new_attrs.push(r#"role="alertdialog""#.to_string());
1195 } else {
1196 new_attrs.push(r#"role="dialog""#.to_string());
1198 }
1199 }
1200 if !found_aria_modal {
1205 new_attrs.push(r#"aria-modal="true""#.to_string());
1206 }
1207
1208 let p_selector =
1210 Selector::parse("p, .dialog-description").unwrap();
1211 let mut doc_inner =
1212 Html::parse_fragment(&modal_elem.inner_html());
1213 let mut maybe_describedby = None;
1214
1215 if let Some(descriptive_elem) =
1216 doc_inner.select(&p_selector).next()
1217 {
1218 let desc_id: String = if let Some(id_val) =
1220 descriptive_elem.value().attr("id")
1221 {
1222 id_val.to_string()
1224 } else {
1225 let generated_id =
1227 format!("dialog-desc-{}", uuid::Uuid::new_v4());
1228 let old_snippet = descriptive_elem.html();
1229
1230 let new_opening_tag = format!(
1233 r#"<{} id="{}""#,
1234 descriptive_elem.value().name(),
1235 generated_id
1236 );
1237
1238 let rest_of_tag = old_snippet
1241 .strip_prefix(&format!(
1242 "<{}",
1243 descriptive_elem.value().name()
1244 ))
1245 .unwrap_or("");
1246
1247 let new_snippet =
1249 format!("{}{}", new_opening_tag, rest_of_tag);
1250
1251 let updated_inner = replace_html_element_resilient(
1253 &modal_elem.inner_html(),
1254 &old_snippet,
1255 &new_snippet,
1256 );
1257 doc_inner = Html::parse_fragment(&updated_inner);
1258
1259 generated_id
1260 };
1261
1262 maybe_describedby = Some(desc_id);
1263 }
1264
1265 let already_has_describedby = new_attrs
1267 .iter()
1268 .any(|attr| attr.starts_with("aria-describedby"));
1269
1270 if let Some(desc_id) = maybe_describedby {
1271 if !already_has_describedby {
1272 new_attrs
1273 .push(format!(r#"aria-describedby="{}""#, desc_id));
1274 }
1275 }
1276
1277 let children_html = doc_inner.root_element().inner_html();
1280
1281 let new_modal_html = format!(
1282 r#"<div {}>{}</div>"#,
1283 new_attrs.join(" "),
1284 children_html
1285 );
1286
1287 eprintln!(
1288 "Replacing modal: {}\nwith: {}\n",
1289 old_modal_html, new_modal_html
1290 );
1291
1292 html_builder.content = replace_html_element_resilient(
1294 &html_builder.content,
1295 &old_modal_html,
1296 &new_modal_html,
1297 );
1298 }
1299
1300 Ok(html_builder)
1301}
1302
1303fn add_aria_to_accordions(
1304 mut html_builder: HtmlBuilder,
1305) -> Result<HtmlBuilder> {
1306 let document = Html::parse_document(&html_builder.content);
1307
1308 if let Ok(accordion_selector) = Selector::parse(".accordion") {
1310 for accordion in document.select(&accordion_selector) {
1311 let accordion_html = accordion.html();
1312 let mut new_html =
1313 String::from("<div class=\"accordion\">");
1314
1315 if let (Ok(button_selector), Ok(content_selector)) = (
1317 Selector::parse("button"),
1318 Selector::parse("button + div"),
1319 ) {
1320 let buttons = accordion.select(&button_selector);
1321 let contents = accordion.select(&content_selector);
1322
1323 for (i, (button, content)) in
1325 buttons.zip(contents).enumerate()
1326 {
1327 let button_text = button.inner_html();
1328 let content_text = content.inner_html();
1329 let section_num = i + 1;
1330
1331 new_html.push_str(&format!(
1333 r#"<button aria-expanded="false" aria-controls="section-{}-content" id="section-{}-button">{}</button><div id="section-{}-content" aria-labelledby="section-{}-button" hidden>{}</div>"#,
1334 section_num, section_num, button_text,
1335 section_num, section_num, content_text
1336 ));
1337 }
1338 }
1339
1340 new_html.push_str("</div>");
1341
1342 html_builder.content = html_builder
1344 .content
1345 .replace(&accordion_html, &new_html);
1346 }
1347 }
1348
1349 Ok(html_builder)
1350}
1351
1352fn add_aria_to_inputs(
1354 mut html_builder: HtmlBuilder,
1355) -> Result<HtmlBuilder> {
1356 if let Some(regex) = INPUT_REGEX.as_ref() {
1357 let mut replacements: Vec<(String, String)> = Vec::new();
1358 let mut id_counter = 0;
1359
1360 for cap in regex.captures_iter(&html_builder.content) {
1362 let input_tag = &cap[0];
1363
1364 if input_tag.contains("aria-label")
1366 || has_associated_label(
1367 input_tag,
1368 &html_builder.content,
1369 )
1370 {
1371 continue;
1372 }
1373
1374 let input_type = extract_input_type(input_tag)
1376 .unwrap_or_else(|| "text".to_string());
1377
1378 match input_type.as_str() {
1379 "text" | "search" | "tel" | "url" | "email"
1381 | "password" | "hidden" | "submit" | "reset"
1382 | "button" | "image" => {
1383 }
1385
1386 "checkbox" | "radio" => {
1388 let attributes = preserve_attributes(input_tag);
1390
1391 let re_id = Regex::new(r#"id="([^"]+)""#).unwrap();
1393 if let Some(id_match) = re_id.captures(&attributes)
1394 {
1395 let existing_id = &id_match[1];
1397 let attributes_no_id =
1400 re_id.replace(&attributes, "").to_string();
1401
1402 let label_text = if input_type == "checkbox" {
1404 format!("Checkbox for {}", existing_id)
1405 } else {
1406 "Option".to_string()
1407 };
1408
1409 let enhanced_input = format!(
1411 r#"<{} id="{}"><label for="{}">{}</label>"#,
1412 attributes_no_id.trim(),
1413 existing_id,
1414 existing_id,
1415 label_text
1416 );
1417 replacements.push((
1418 input_tag.to_string(),
1419 enhanced_input,
1420 ));
1421 } else {
1422 id_counter += 1;
1424 let new_id = format!("option{}", id_counter);
1425 let label_text = if input_type == "checkbox" {
1426 "Checkbox".to_string()
1427 } else {
1428 format!("Option {}", id_counter)
1429 };
1430
1431 let enhanced_input = format!(
1432 r#"<{} id="{}"><label for="{}">{}</label>"#,
1433 attributes, new_id, new_id, label_text
1434 );
1435 replacements.push((
1436 input_tag.to_string(),
1437 enhanced_input,
1438 ));
1439 }
1440 }
1441
1442 _ => {
1444 let attributes = preserve_attributes(input_tag);
1445 let enhanced_input = format!(
1446 r#"<input {} aria-label="{}">"#,
1447 attributes, input_type
1448 );
1449 replacements
1450 .push((input_tag.to_string(), enhanced_input));
1451 }
1452 }
1453 }
1454
1455 for (old, new) in replacements {
1457 html_builder.content =
1458 html_builder.content.replace(&old, &new);
1459 }
1460 }
1461
1462 Ok(html_builder)
1463}
1464
1465fn has_associated_label(input_tag: &str, html_content: &str) -> bool {
1467 if let Some(id_match) =
1468 Regex::new(r#"id="([^"]+)""#).unwrap().captures(input_tag)
1469 {
1470 let id = &id_match[1];
1471 Regex::new(&format!(r#"<label\s+for="{}"\s*>"#, id))
1472 .unwrap()
1473 .is_match(html_content)
1474 } else {
1475 false
1476 }
1477}
1478
1479static ATTRIBUTE_REGEX: Lazy<Regex> = Lazy::new(|| {
1481 Regex::new(
1482 r#"(?:data-\w+|[a-zA-Z]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\S+))?"#,
1483 )
1484 .unwrap()
1485});
1486
1487fn preserve_attributes(input_tag: &str) -> String {
1489 ATTRIBUTE_REGEX
1490 .captures_iter(input_tag)
1491 .map(|cap| cap[0].to_string())
1492 .collect::<Vec<String>>()
1493 .join(" ")
1494}
1495
1496fn extract_input_type(input_tag: &str) -> Option<String> {
1498 static TYPE_REGEX: Lazy<Regex> = Lazy::new(|| {
1499 Regex::new(r#"type=["']([^"']+)["']"#)
1500 .expect("Failed to create type regex")
1501 });
1502
1503 TYPE_REGEX
1504 .captures(input_tag)
1505 .and_then(|cap| cap.get(1))
1506 .map(|m| m.as_str().to_string())
1507}
1508
1509fn generate_unique_id() -> String {
1511 format!("aria-{}", uuid::Uuid::new_v4())
1512}
1513
1514fn validate_aria(html: &str) -> bool {
1516 let document = Html::parse_document(html);
1517
1518 if let Some(selector) = ARIA_SELECTOR.as_ref() {
1519 document
1520 .select(selector)
1521 .flat_map(|el| el.value().attrs())
1522 .filter(|(name, _)| name.starts_with("aria-"))
1523 .all(|(name, value)| is_valid_aria_attribute(name, value))
1524 } else {
1525 eprintln!("ARIA_SELECTOR failed to initialize.");
1526 false
1527 }
1528}
1529
1530fn remove_invalid_aria_attributes(html: &str) -> String {
1531 let document = Html::parse_document(html);
1532 let mut new_html = html.to_string();
1533
1534 if let Some(selector) = ARIA_SELECTOR.as_ref() {
1535 for element in document.select(selector) {
1536 let element_html = element.html();
1537 let mut updated_html = element_html.clone();
1538
1539 for (attr_name, attr_value) in element.value().attrs() {
1540 if attr_name.starts_with("aria-")
1541 && !is_valid_aria_attribute(attr_name, attr_value)
1542 {
1543 updated_html = updated_html.replace(
1544 &format!(r#" {}="{}""#, attr_name, attr_value),
1545 "",
1546 );
1547 }
1548 }
1549
1550 new_html = new_html.replace(&element_html, &updated_html);
1551 }
1552 }
1553
1554 new_html
1555}
1556
1557fn is_valid_aria_attribute(name: &str, value: &str) -> bool {
1559 if !VALID_ARIA_ATTRIBUTES.contains(name) {
1560 return false; }
1562
1563 match name {
1564 "aria-hidden" | "aria-expanded" | "aria-pressed"
1565 | "aria-invalid" => {
1566 matches!(value, "true" | "false") }
1568 "aria-level" => value.parse::<u32>().is_ok(), _ => !value.trim().is_empty(), }
1571}
1572
1573fn check_language_attributes(
1574 document: &Html,
1575 issues: &mut Vec<Issue>,
1576) -> Result<()> {
1577 if let Some(html_element) =
1578 document.select(&Selector::parse("html").unwrap()).next()
1579 {
1580 if html_element.value().attr("lang").is_none() {
1581 AccessibilityReport::add_issue(
1582 issues,
1583 IssueType::LanguageDeclaration,
1584 "Missing language declaration on HTML element",
1585 Some("WCAG 3.1.1".to_string()),
1586 Some("<html>".to_string()),
1587 Some("Add lang attribute to HTML element".to_string()),
1588 );
1589 }
1590 }
1591
1592 for element in document.select(&Selector::parse("[lang]").unwrap())
1593 {
1594 if let Some(lang) = element.value().attr("lang") {
1595 if !is_valid_language_code(lang) {
1596 AccessibilityReport::add_issue(
1597 issues,
1598 IssueType::LanguageDeclaration,
1599 format!("Invalid language code: {}", lang),
1600 Some("WCAG 3.1.2".to_string()),
1601 Some(element.html()),
1602 Some("Use valid BCP 47 language code".to_string()),
1603 );
1604 }
1605 }
1606 }
1607 Ok(())
1608}
1609
1610impl AccessibilityReport {
1612 pub fn check_keyboard_navigation(
1614 document: &Html,
1615 issues: &mut Vec<Issue>,
1616 ) -> Result<()> {
1617 let binding = Selector::parse(
1618 "a, button, input, select, textarea, [tabindex]",
1619 )
1620 .unwrap();
1621 let interactive_elements = document.select(&binding);
1622
1623 for element in interactive_elements {
1624 if let Some(tabindex) = element.value().attr("tabindex") {
1626 if let Ok(index) = tabindex.parse::<i32>() {
1627 if index < 0 {
1628 issues.push(Issue {
1629 issue_type: IssueType::KeyboardNavigation,
1630 message: "Negative tabindex prevents keyboard focus".to_string(),
1631 guideline: Some("WCAG 2.1.1".to_string()),
1632 element: Some(element.html()),
1633 suggestion: Some("Remove negative tabindex value".to_string()),
1634 });
1635 }
1636 }
1637 }
1638
1639 if element.value().attr("onclick").is_some()
1641 && element.value().attr("onkeypress").is_none()
1642 && element.value().attr("onkeydown").is_none()
1643 {
1644 issues.push(Issue {
1645 issue_type: IssueType::KeyboardNavigation,
1646 message:
1647 "Click handler without keyboard equivalent"
1648 .to_string(),
1649 guideline: Some("WCAG 2.1.1".to_string()),
1650 element: Some(element.html()),
1651 suggestion: Some(
1652 "Add keyboard event handlers".to_string(),
1653 ),
1654 });
1655 }
1656 }
1657 Ok(())
1658 }
1659
1660 pub fn check_language_attributes(
1662 document: &Html,
1663 issues: &mut Vec<Issue>,
1664 ) -> Result<()> {
1665 let html_element =
1667 document.select(&Selector::parse("html").unwrap()).next();
1668 if let Some(element) = html_element {
1669 if element.value().attr("lang").is_none() {
1670 Self::add_issue(
1671 issues,
1672 IssueType::LanguageDeclaration,
1673 "Missing language declaration",
1674 Some("WCAG 3.1.1".to_string()),
1675 Some(element.html()),
1676 Some(
1677 "Add lang attribute to html element"
1678 .to_string(),
1679 ),
1680 );
1681 }
1682 }
1683
1684 let binding = Selector::parse("[lang]").unwrap();
1686 let text_elements = document.select(&binding);
1687 for element in text_elements {
1688 if let Some(lang) = element.value().attr("lang") {
1689 if !is_valid_language_code(lang) {
1690 Self::add_issue(
1691 issues,
1692 IssueType::LanguageDeclaration,
1693 format!("Invalid language code: {}", lang),
1694 Some("WCAG 3.1.2".to_string()),
1695 Some(element.html()),
1696 Some(
1697 "Use valid BCP 47 language code"
1698 .to_string(),
1699 ),
1700 );
1701 }
1702 }
1703 }
1704 Ok(())
1705 }
1706
1707 pub fn check_advanced_aria(
1709 document: &Html,
1710 issues: &mut Vec<Issue>,
1711 ) -> Result<()> {
1712 let binding = Selector::parse("[role]").unwrap();
1714 let elements_with_roles = document.select(&binding);
1715 for element in elements_with_roles {
1716 if let Some(role) = element.value().attr("role") {
1717 if !is_valid_aria_role(role, &element) {
1718 Self::add_issue(
1719 issues,
1720 IssueType::InvalidAria,
1721 format!(
1722 "Invalid ARIA role '{}' for element",
1723 role
1724 ),
1725 Some("WCAG 4.1.2".to_string()),
1726 Some(element.html()),
1727 Some("Use appropriate ARIA role".to_string()),
1728 );
1729 }
1730 }
1731 }
1732
1733 let elements_with_aria =
1735 document.select(ARIA_SELECTOR.as_ref().unwrap());
1736 for element in elements_with_aria {
1737 if let Some(missing_props) =
1738 get_missing_required_aria_properties(&element)
1739 {
1740 Self::add_issue(
1741 issues,
1742 IssueType::InvalidAria,
1743 format!(
1744 "Missing required ARIA properties: {}",
1745 missing_props.join(", ")
1746 ),
1747 Some("WCAG 4.1.2".to_string()),
1748 Some(element.html()),
1749 Some("Add required ARIA properties".to_string()),
1750 );
1751 }
1752 }
1753 Ok(())
1754 }
1755}
1756
1757pub mod utils {
1759 use scraper::ElementRef;
1760 use std::collections::HashMap;
1761
1762 use once_cell::sync::Lazy;
1764 use regex::Regex;
1765
1766 pub(crate) fn is_valid_language_code(lang: &str) -> bool {
1768 static LANGUAGE_CODE_REGEX: Lazy<Regex> = Lazy::new(|| {
1769 Regex::new(r"(?i)^[a-z]{2,3}(-[a-z0-9]{2,8})*$").unwrap()
1771 });
1772
1773 LANGUAGE_CODE_REGEX.is_match(lang) && !lang.ends_with('-')
1775 }
1776
1777 pub(crate) fn is_valid_aria_role(
1779 role: &str,
1780 element: &ElementRef,
1781 ) -> bool {
1782 static VALID_ROLES: Lazy<HashMap<&str, Vec<&str>>> =
1783 Lazy::new(|| {
1784 let mut map = HashMap::new();
1785 _ = map.insert(
1786 "button",
1787 vec!["button", "link", "menuitem"],
1788 );
1789 _ = map.insert(
1790 "input",
1791 vec!["textbox", "radio", "checkbox", "button"],
1792 );
1793 _ = map.insert(
1794 "div",
1795 vec!["alert", "tooltip", "dialog", "slider"],
1796 );
1797 _ = map.insert("a", vec!["link", "button", "menuitem"]);
1798 map
1799 });
1800
1801 let tag_name = element.value().name();
1803 if ["div", "span", "a"].contains(&tag_name) {
1804 return true;
1805 }
1806
1807 if let Some(valid_roles) = VALID_ROLES.get(tag_name) {
1809 valid_roles.contains(&role)
1810 } else {
1811 false
1812 }
1813 }
1814
1815 pub(crate) fn get_missing_required_aria_properties(
1817 element: &ElementRef,
1818 ) -> Option<Vec<String>> {
1819 let mut missing = Vec::new();
1820
1821 static REQUIRED_ARIA_PROPS: Lazy<HashMap<&str, Vec<&str>>> =
1822 Lazy::new(|| {
1823 HashMap::from([
1824 (
1825 "slider",
1826 vec![
1827 "aria-valuenow",
1828 "aria-valuemin",
1829 "aria-valuemax",
1830 ],
1831 ),
1832 ("combobox", vec!["aria-expanded"]),
1833 ])
1834 });
1835
1836 if let Some(role) = element.value().attr("role") {
1837 if let Some(required_props) = REQUIRED_ARIA_PROPS.get(role)
1838 {
1839 for prop in required_props {
1840 if element.value().attr(prop).is_none() {
1841 missing.push(prop.to_string());
1842 }
1843 }
1844 }
1845 }
1846
1847 if missing.is_empty() {
1848 None
1849 } else {
1850 Some(missing)
1851 }
1852 }
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857 use super::*;
1858
1859 mod wcag_level_tests {
1861 use super::*;
1862
1863 #[test]
1864 fn test_wcag_level_ordering() {
1865 assert!(matches!(WcagLevel::A, WcagLevel::A));
1866 assert!(matches!(WcagLevel::AA, WcagLevel::AA));
1867 assert!(matches!(WcagLevel::AAA, WcagLevel::AAA));
1868 }
1869
1870 #[test]
1871 fn test_wcag_level_debug() {
1872 assert_eq!(format!("{:?}", WcagLevel::A), "A");
1873 assert_eq!(format!("{:?}", WcagLevel::AA), "AA");
1874 assert_eq!(format!("{:?}", WcagLevel::AAA), "AAA");
1875 }
1876 }
1877
1878 mod config_tests {
1880 use super::*;
1881
1882 #[test]
1883 fn test_default_config() {
1884 let config = AccessibilityConfig::default();
1885 assert_eq!(config.wcag_level, WcagLevel::AA);
1886 assert_eq!(config.max_heading_jump, 1);
1887 assert_eq!(config.min_contrast_ratio, 4.5);
1888 assert!(config.auto_fix);
1889 }
1890
1891 #[test]
1892 fn test_custom_config() {
1893 let config = AccessibilityConfig {
1894 wcag_level: WcagLevel::AAA,
1895 max_heading_jump: 2,
1896 min_contrast_ratio: 7.0,
1897 auto_fix: false,
1898 };
1899 assert_eq!(config.wcag_level, WcagLevel::AAA);
1900 assert_eq!(config.max_heading_jump, 2);
1901 assert_eq!(config.min_contrast_ratio, 7.0);
1902 assert!(!config.auto_fix);
1903 }
1904 }
1905
1906 mod aria_attribute_tests {
1908 use super::*;
1909
1910 #[test]
1911 fn test_valid_aria_attributes() {
1912 assert!(is_valid_aria_attribute("aria-label", "Test"));
1913 assert!(is_valid_aria_attribute("aria-hidden", "true"));
1914 assert!(is_valid_aria_attribute("aria-hidden", "false"));
1915 assert!(!is_valid_aria_attribute("aria-hidden", "yes"));
1916 assert!(!is_valid_aria_attribute("invalid-aria", "value"));
1917 }
1918
1919 #[test]
1920 fn test_empty_aria_value() {
1921 assert!(!is_valid_aria_attribute("aria-label", ""));
1922 assert!(!is_valid_aria_attribute("aria-label", " "));
1923 }
1924 }
1925
1926 mod html_modification_tests {
1928 use super::*;
1929
1930 #[test]
1931 fn test_add_aria_to_empty_button() {
1932 let html = "<button></button>";
1933 let result = add_aria_attributes(html, None);
1934 assert!(result.is_ok());
1935 let enhanced = result.unwrap();
1936 assert!(enhanced.contains(r#"aria-label="button""#));
1937 }
1938
1939 #[test]
1940 fn test_large_input() {
1941 let large_html = "a".repeat(MAX_HTML_SIZE + 1);
1942 let result = add_aria_attributes(&large_html, None);
1943 assert!(matches!(result, Err(Error::HtmlTooLarge { .. })));
1944 }
1945 }
1946
1947 mod validation_tests {
1949 use super::*;
1950
1951 #[test]
1952 fn test_heading_structure() {
1953 let valid_html = "<h1>Main Title</h1><h2>Subtitle</h2>";
1954 let invalid_html =
1955 "<h1>Main Title</h1><h3>Skipped Heading</h3>";
1956
1957 let config = AccessibilityConfig::default();
1958
1959 let valid_result = validate_wcag(
1961 valid_html,
1962 &config,
1963 Some(&[IssueType::LanguageDeclaration]),
1964 )
1965 .unwrap();
1966 assert_eq!(
1967 valid_result.issue_count, 0,
1968 "Expected no issues for valid HTML, but found: {:#?}",
1969 valid_result.issues
1970 );
1971
1972 let invalid_result = validate_wcag(
1974 invalid_html,
1975 &config,
1976 Some(&[IssueType::LanguageDeclaration]),
1977 )
1978 .unwrap();
1979 assert_eq!(
1980 invalid_result.issue_count,
1981 1,
1982 "Expected one issue for skipped heading levels, but found: {:#?}",
1983 invalid_result.issues
1984 );
1985
1986 let issue = &invalid_result.issues[0];
1987 assert_eq!(issue.issue_type, IssueType::HeadingStructure);
1988 assert_eq!(
1989 issue.message,
1990 "Skipped heading level from h1 to h3"
1991 );
1992 assert_eq!(issue.guideline, Some("WCAG 2.4.6".to_string()));
1993 assert_eq!(
1994 issue.suggestion,
1995 Some("Use sequential heading levels".to_string())
1996 );
1997 }
1998 }
1999
2000 mod report_tests {
2002 use super::*;
2003
2004 #[test]
2005 fn test_report_generation() {
2006 let html = r#"<img src="test.jpg">"#;
2007 let config = AccessibilityConfig::default();
2008 let report = validate_wcag(html, &config, None).unwrap();
2009
2010 assert!(report.issue_count > 0);
2011
2012 assert_eq!(report.wcag_level, WcagLevel::AA);
2013 }
2014
2015 #[test]
2016 fn test_empty_html_report() {
2017 let html = "";
2018 let config = AccessibilityConfig::default();
2019 let report = validate_wcag(html, &config, None).unwrap();
2020
2021 assert_eq!(report.elements_checked, 0);
2022 assert_eq!(report.issue_count, 0);
2023 }
2024
2025 #[test]
2026 fn test_missing_selector_handling() {
2027 static TEST_NAV_SELECTOR: Lazy<Option<Selector>> =
2029 Lazy::new(|| None);
2030
2031 let html = "<nav>Main Navigation</nav>";
2032 let document = Html::parse_document(html);
2033
2034 if let Some(selector) = TEST_NAV_SELECTOR.as_ref() {
2035 let navs: Vec<_> = document.select(selector).collect();
2036 assert_eq!(navs.len(), 0);
2037 }
2038 }
2039
2040 #[test]
2041 fn test_html_processing_error_with_source() {
2042 let source_error = std::io::Error::new(
2043 std::io::ErrorKind::Other,
2044 "test source error",
2045 );
2046 let error = Error::HtmlProcessingError {
2047 message: "Processing failed".to_string(),
2048 source: Some(Box::new(source_error)),
2049 };
2050
2051 assert_eq!(
2052 format!("{}", error),
2053 "HTML Processing Error: Processing failed"
2054 );
2055 }
2056 }
2057 #[cfg(test)]
2058 mod utils_tests {
2059 use super::*;
2060
2061 mod language_code_validation {
2062 use super::*;
2063
2064 #[test]
2065 fn test_valid_language_codes() {
2066 let valid_codes = [
2067 "en", "en-US", "zh-CN", "fr-FR", "de-DE", "es-419",
2068 "ar-001", "pt-BR", "ja-JP", "ko-KR",
2069 ];
2070 for code in valid_codes {
2071 assert!(
2072 is_valid_language_code(code),
2073 "Language code '{}' should be valid",
2074 code
2075 );
2076 }
2077 }
2078
2079 #[test]
2080 fn test_invalid_language_codes() {
2081 let invalid_codes = [
2082 "", "a", "123", "en_US", "en-", "-en", "en--US", "toolong", "en-US-INVALID-", ];
2092 for code in invalid_codes {
2093 assert!(
2094 !is_valid_language_code(code),
2095 "Language code '{}' should be invalid",
2096 code
2097 );
2098 }
2099 }
2100
2101 #[test]
2102 fn test_language_code_case_sensitivity() {
2103 assert!(is_valid_language_code("en-GB"));
2104 assert!(is_valid_language_code("fr-FR"));
2105 assert!(is_valid_language_code("zh-Hans"));
2106 assert!(is_valid_language_code("EN-GB"));
2107 }
2108 }
2109
2110 mod aria_role_validation {
2111 use super::*;
2112
2113 #[test]
2114 fn test_valid_button_roles() {
2115 let html = "<button>Test</button>";
2116 let fragment = Html::parse_fragment(html);
2117 let selector = Selector::parse("button").unwrap();
2118 let element =
2119 fragment.select(&selector).next().unwrap();
2120 let valid_roles = ["button", "link", "menuitem"];
2121 for role in valid_roles {
2122 assert!(
2123 is_valid_aria_role(role, &element),
2124 "Role '{}' should be valid for button",
2125 role
2126 );
2127 }
2128 }
2129
2130 #[test]
2131 fn test_valid_input_roles() {
2132 let html = "<input type='text'>";
2133 let fragment = Html::parse_fragment(html);
2134 let selector = Selector::parse("input").unwrap();
2135 let element =
2136 fragment.select(&selector).next().unwrap();
2137 let valid_roles =
2138 ["textbox", "radio", "checkbox", "button"];
2139 for role in valid_roles {
2140 assert!(
2141 is_valid_aria_role(role, &element),
2142 "Role '{}' should be valid for input",
2143 role
2144 );
2145 }
2146 }
2147
2148 #[test]
2149 fn test_valid_anchor_roles() {
2150 let html = "<a href=\"\\#\">Test</a>";
2151 let fragment = Html::parse_fragment(html);
2152 let selector = Selector::parse("a").unwrap();
2153 let element =
2154 fragment.select(&selector).next().unwrap();
2155
2156 let valid_roles = ["button", "link", "menuitem"];
2157 for role in valid_roles {
2158 assert!(
2159 is_valid_aria_role(role, &element),
2160 "Role '{}' should be valid for anchor",
2161 role
2162 );
2163 }
2164 }
2165
2166 #[test]
2167 fn test_invalid_element_roles() {
2168 let html = "<button>Test</button>";
2169 let fragment = Html::parse_fragment(html);
2170 let selector = Selector::parse("button").unwrap();
2171 let element =
2172 fragment.select(&selector).next().unwrap();
2173 let invalid_roles =
2174 ["textbox", "radio", "checkbox", "invalid"];
2175 for role in invalid_roles {
2176 assert!(
2177 !is_valid_aria_role(role, &element),
2178 "Role '{}' should be invalid for button",
2179 role
2180 );
2181 }
2182 }
2183
2184 #[test]
2185 fn test_unrestricted_elements() {
2186 let html_div = "<div>Test</div>";
2188 let fragment_div = Html::parse_fragment(html_div);
2189 let selector_div = Selector::parse("div").unwrap();
2190 let element_div =
2191 fragment_div.select(&selector_div).next().unwrap();
2192
2193 let html_span = "<span>Test</span>";
2195 let fragment_span = Html::parse_fragment(html_span);
2196 let selector_span = Selector::parse("span").unwrap();
2197 let element_span = fragment_span
2198 .select(&selector_span)
2199 .next()
2200 .unwrap();
2201
2202 let roles =
2203 ["button", "textbox", "navigation", "banner"];
2204
2205 for role in roles {
2206 assert!(
2207 is_valid_aria_role(role, &element_div),
2208 "Role '{}' should be allowed for div",
2209 role
2210 );
2211 assert!(
2212 is_valid_aria_role(role, &element_span),
2213 "Role '{}' should be allowed for span",
2214 role
2215 );
2216 }
2217 }
2218
2219 #[test]
2220 fn test_validate_wcag_with_level_aaa() {
2221 let html =
2222 "<h1>Main Title</h1><h3>Skipped Heading</h3>";
2223 let config = AccessibilityConfig {
2224 wcag_level: WcagLevel::AAA,
2225 ..Default::default()
2226 };
2227 let report =
2228 validate_wcag(html, &config, None).unwrap();
2229 assert!(report.issue_count > 0);
2230 assert_eq!(report.wcag_level, WcagLevel::AAA);
2231 }
2232
2233 #[test]
2234 fn test_html_builder_empty() {
2235 let builder = HtmlBuilder::new("");
2236 assert_eq!(builder.build(), "");
2237 }
2238
2239 #[test]
2240 fn test_generate_unique_id_uniqueness() {
2241 let id1 = generate_unique_id();
2242 let id2 = generate_unique_id();
2243 assert_ne!(id1, id2);
2244 }
2245 }
2246
2247 mod required_aria_properties {
2248 use super::*;
2249 use scraper::{Html, Selector};
2250
2251 #[test]
2252 fn test_combobox_required_properties() {
2253 let html = r#"<div role="combobox">Test</div>"#;
2254 let fragment = Html::parse_fragment(html);
2255 let selector = Selector::parse("div").unwrap();
2256 let element =
2257 fragment.select(&selector).next().unwrap();
2258
2259 let missing =
2260 get_missing_required_aria_properties(&element)
2261 .unwrap();
2262 assert!(missing.contains(&"aria-expanded".to_string()));
2263 }
2264
2265 #[test]
2266 fn test_complete_combobox() {
2267 let html = r#"<div role="combobox" aria-expanded="true">Test</div>"#;
2268 let fragment = Html::parse_fragment(html);
2269 let selector = Selector::parse("div").unwrap();
2270 let element =
2271 fragment.select(&selector).next().unwrap();
2272
2273 let missing =
2274 get_missing_required_aria_properties(&element);
2275 assert!(missing.is_none());
2276 }
2277
2278 #[test]
2279 fn test_add_aria_attributes_empty_html() {
2280 let html = "";
2281 let result = add_aria_attributes(html, None);
2282 assert!(result.is_ok());
2283 assert_eq!(result.unwrap(), "");
2284 }
2285
2286 #[test]
2287 fn test_add_aria_attributes_whitespace_html() {
2288 let html = " ";
2289 let result = add_aria_attributes(html, None);
2290 assert!(result.is_ok());
2291 assert_eq!(result.unwrap(), " ");
2292 }
2293
2294 #[test]
2295 fn test_validate_wcag_with_minimal_config() {
2296 let html = r#"<html lang="en"><div>Accessible Content</div></html>"#;
2297 let config = AccessibilityConfig {
2298 wcag_level: WcagLevel::A,
2299 max_heading_jump: 0, min_contrast_ratio: 0.0, auto_fix: false,
2302 };
2303 let report =
2304 validate_wcag(html, &config, None).unwrap();
2305 assert_eq!(report.issue_count, 0);
2306 }
2307
2308 #[test]
2309 fn test_add_partial_aria_attributes_to_button() {
2310 let html =
2311 r#"<button aria-label="Existing">Click</button>"#;
2312 let result = add_aria_attributes(html, None);
2313 assert!(result.is_ok());
2314 let enhanced = result.unwrap();
2315 assert!(enhanced.contains(r#"aria-label="Existing""#));
2316 }
2317
2318 #[test]
2319 fn test_add_aria_to_elements_with_existing_roles() {
2320 let html = r#"<nav aria-label=\"navigation\" role=\"navigation\" role=\"navigation\">Content</nav>"#;
2321 let result = add_aria_attributes(html, None);
2322 assert!(result.is_ok());
2323 assert_eq!(result.unwrap(), html);
2324 }
2325
2326 #[test]
2327 fn test_slider_required_properties() {
2328 let html = r#"<div role="slider">Test</div>"#;
2329 let fragment = Html::parse_fragment(html);
2330 let selector = Selector::parse("div").unwrap();
2331 let element =
2332 fragment.select(&selector).next().unwrap();
2333
2334 let missing =
2335 get_missing_required_aria_properties(&element)
2336 .unwrap();
2337
2338 assert!(missing.contains(&"aria-valuenow".to_string()));
2339 assert!(missing.contains(&"aria-valuemin".to_string()));
2340 assert!(missing.contains(&"aria-valuemax".to_string()));
2341 }
2342
2343 #[test]
2344 fn test_complete_slider() {
2345 let html = r#"<div role="slider"
2346 aria-valuenow="50"
2347 aria-valuemin="0"
2348 aria-valuemax="100">Test</div>"#;
2349 let fragment = Html::parse_fragment(html);
2350 let selector = Selector::parse("div").unwrap();
2351 let element =
2352 fragment.select(&selector).next().unwrap();
2353
2354 let missing =
2355 get_missing_required_aria_properties(&element);
2356 assert!(missing.is_none());
2357 }
2358
2359 #[test]
2360 fn test_partial_slider_properties() {
2361 let html = r#"<div role="slider" aria-valuenow="50">Test</div>"#;
2362 let fragment = Html::parse_fragment(html);
2363 let selector = Selector::parse("div").unwrap();
2364 let element =
2365 fragment.select(&selector).next().unwrap();
2366
2367 let missing =
2368 get_missing_required_aria_properties(&element)
2369 .unwrap();
2370
2371 assert!(!missing.contains(&"aria-valuenow".to_string()));
2372 assert!(missing.contains(&"aria-valuemin".to_string()));
2373 assert!(missing.contains(&"aria-valuemax".to_string()));
2374 }
2375
2376 #[test]
2377 fn test_unknown_role() {
2378 let html = r#"<div role="unknown">Test</div>"#;
2379 let fragment = Html::parse_fragment(html);
2380 let selector = Selector::parse("div").unwrap();
2381 let element =
2382 fragment.select(&selector).next().unwrap();
2383
2384 let missing =
2385 get_missing_required_aria_properties(&element);
2386 assert!(missing.is_none());
2387 }
2388
2389 #[test]
2390 fn test_no_role() {
2391 let html = "<div>Test</div>";
2392 let fragment = Html::parse_fragment(html);
2393 let selector = Selector::parse("div").unwrap();
2394 let element =
2395 fragment.select(&selector).next().unwrap();
2396
2397 let missing =
2398 get_missing_required_aria_properties(&element);
2399 assert!(missing.is_none());
2400 }
2401 }
2402 }
2403
2404 #[cfg(test)]
2405 mod accessibility_tests {
2406 use crate::accessibility::{
2407 get_missing_required_aria_properties, is_valid_aria_role,
2408 is_valid_language_code,
2409 };
2410 use scraper::Selector;
2411
2412 #[test]
2413 fn test_is_valid_language_code() {
2414 assert!(
2415 is_valid_language_code("en"),
2416 "Valid language code 'en' was incorrectly rejected"
2417 );
2418 assert!(
2419 is_valid_language_code("en-US"),
2420 "Valid language code 'en-US' was incorrectly rejected"
2421 );
2422 assert!(
2423 !is_valid_language_code("123"),
2424 "Invalid language code '123' was incorrectly accepted"
2425 );
2426 assert!(!is_valid_language_code("日本語"), "Non-ASCII language code '日本語' was incorrectly accepted");
2427 }
2428
2429 #[test]
2430 fn test_is_valid_aria_role() {
2431 use scraper::Html;
2432
2433 let html = r#"<button></button>"#;
2434 let document = Html::parse_fragment(html);
2435 let element = document
2436 .select(&Selector::parse("button").unwrap())
2437 .next()
2438 .unwrap();
2439
2440 assert!(
2441 is_valid_aria_role("button", &element),
2442 "Valid ARIA role 'button' was incorrectly rejected"
2443 );
2444
2445 assert!(
2446 !is_valid_aria_role("invalid-role", &element),
2447 "Invalid ARIA role 'invalid-role' was incorrectly accepted"
2448 );
2449 }
2450
2451 #[test]
2452 fn test_get_missing_required_aria_properties() {
2453 use scraper::{Html, Selector};
2454
2455 let html = r#"<div role="slider"></div>"#;
2457 let document = Html::parse_fragment(html);
2458 let element = document
2459 .select(&Selector::parse("div").unwrap())
2460 .next()
2461 .unwrap();
2462
2463 let missing_props =
2464 get_missing_required_aria_properties(&element).unwrap();
2465 assert!(
2466 missing_props.contains(&"aria-valuenow".to_string()),
2467 "Did not detect missing 'aria-valuenow' for role 'slider'"
2468 );
2469 assert!(
2470 missing_props.contains(&"aria-valuemin".to_string()),
2471 "Did not detect missing 'aria-valuemin' for role 'slider'"
2472 );
2473 assert!(
2474 missing_props.contains(&"aria-valuemax".to_string()),
2475 "Did not detect missing 'aria-valuemax' for role 'slider'"
2476 );
2477
2478 let html = r#"<div role="slider" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>"#;
2480 let document = Html::parse_fragment(html);
2481 let element = document
2482 .select(&Selector::parse("div").unwrap())
2483 .next()
2484 .unwrap();
2485
2486 let missing_props =
2487 get_missing_required_aria_properties(&element);
2488 assert!(missing_props.is_none(), "Unexpectedly found missing properties for a complete slider");
2489
2490 let html =
2492 r#"<div role="slider" aria-valuenow="50"></div>"#;
2493 let document = Html::parse_fragment(html);
2494 let element = document
2495 .select(&Selector::parse("div").unwrap())
2496 .next()
2497 .unwrap();
2498
2499 let missing_props =
2500 get_missing_required_aria_properties(&element).unwrap();
2501 assert!(
2502 !missing_props.contains(&"aria-valuenow".to_string()),
2503 "Incorrectly flagged 'aria-valuenow' as missing"
2504 );
2505 assert!(
2506 missing_props.contains(&"aria-valuemin".to_string()),
2507 "Did not detect missing 'aria-valuemin' for role 'slider'"
2508 );
2509 assert!(
2510 missing_props.contains(&"aria-valuemax".to_string()),
2511 "Did not detect missing 'aria-valuemax' for role 'slider'"
2512 );
2513 }
2514 }
2515
2516 #[cfg(test)]
2517 mod additional_tests {
2518 use super::*;
2519 use scraper::Html;
2520
2521 #[test]
2522 fn test_validate_empty_html() {
2523 let html = "";
2524 let config = AccessibilityConfig::default();
2525 let report = validate_wcag(html, &config, None).unwrap();
2526 assert_eq!(
2527 report.issue_count, 0,
2528 "Empty HTML should not produce issues"
2529 );
2530 }
2531
2532 #[test]
2533 fn test_validate_only_whitespace_html() {
2534 let html = " ";
2535 let config = AccessibilityConfig::default();
2536 let report = validate_wcag(html, &config, None).unwrap();
2537 assert_eq!(
2538 report.issue_count, 0,
2539 "Whitespace-only HTML should not produce issues"
2540 );
2541 }
2542
2543 #[test]
2544 fn test_validate_language_with_edge_cases() {
2545 let html = "<html lang=\"en-US\"></html>";
2546 let _config = AccessibilityConfig::default();
2547 let mut issues = Vec::new();
2548 let document = Html::parse_document(html);
2549
2550 check_language_attributes(&document, &mut issues).unwrap();
2551 assert_eq!(
2552 issues.len(),
2553 0,
2554 "Valid language declaration should not create issues"
2555 );
2556 }
2557
2558 #[test]
2559 fn test_validate_invalid_language_code() {
2560 let html = "<html lang=\"invalid-lang\"></html>";
2561 let _config = AccessibilityConfig::default();
2562 let mut issues = Vec::new();
2563 let document = Html::parse_document(html);
2564
2565 check_language_attributes(&document, &mut issues).unwrap();
2566 assert!(
2567 issues
2568 .iter()
2569 .any(|i| i.issue_type
2570 == IssueType::LanguageDeclaration),
2571 "Failed to detect invalid language declaration"
2572 );
2573 }
2574
2575 #[test]
2576 fn test_edge_case_for_generate_unique_id() {
2577 let ids: Vec<String> =
2578 (0..100).map(|_| generate_unique_id()).collect();
2579 let unique_ids: HashSet<String> = ids.into_iter().collect();
2580 assert_eq!(
2581 unique_ids.len(),
2582 100,
2583 "Generated IDs are not unique in edge case testing"
2584 );
2585 }
2586
2587 #[test]
2588 fn test_enhance_landmarks_noop() {
2589 let html = "<div>Simple Content</div>";
2590 let builder = HtmlBuilder::new(html);
2591 let result = enhance_landmarks(builder);
2592 assert!(
2593 result.is_ok(),
2594 "Failed to handle simple HTML content"
2595 );
2596 assert_eq!(result.unwrap().build(), html, "Landmark enhancement altered simple content unexpectedly");
2597 }
2598
2599 #[test]
2600 fn test_html_with_non_standard_elements() {
2601 let html =
2602 "<custom-element aria-label=\"test\"></custom-element>";
2603 let cleaned_html = remove_invalid_aria_attributes(html);
2604 assert_eq!(cleaned_html, html, "Unexpectedly modified valid custom element with ARIA attributes");
2605 }
2606
2607 #[test]
2608 fn test_add_aria_to_buttons() {
2609 let html = r#"<button>Click me</button>"#;
2610 let builder = HtmlBuilder::new(html);
2611 let result = add_aria_to_buttons(builder).unwrap().build();
2612 assert!(result.contains("aria-label"));
2613 }
2614
2615 #[test]
2616 fn test_add_aria_to_empty_buttons() {
2617 let html = r#"<button></button>"#;
2618 let builder = HtmlBuilder::new(html);
2619 let result = add_aria_to_buttons(builder).unwrap();
2620 assert!(result.build().contains("aria-label"));
2621 }
2622
2623 #[test]
2624 fn test_validate_wcag_empty_html() {
2625 let html = "";
2626 let config = AccessibilityConfig::default();
2627 let disable_checks = None;
2628
2629 let result = validate_wcag(html, &config, disable_checks);
2630
2631 match result {
2632 Ok(report) => assert!(
2633 report.issues.is_empty(),
2634 "Empty HTML should have no issues"
2635 ),
2636 Err(e) => {
2637 panic!("Validation failed with error: {:?}", e)
2638 }
2639 }
2640 }
2641
2642 #[test]
2643 fn test_validate_wcag_with_complex_html() {
2644 let html = "
2645 <html>
2646 <head></head>
2647 <body>
2648 <button>Click me</button>
2649 <a href=\"\\#\"></a>
2650 </body>
2651 </html>
2652 ";
2653 let config = AccessibilityConfig::default();
2654 let disable_checks = None;
2655 let result = validate_wcag(html, &config, disable_checks);
2656
2657 match result {
2658 Ok(report) => assert!(
2659 !report.issues.is_empty(),
2660 "Report should have issues"
2661 ),
2662 Err(e) => {
2663 panic!("Validation failed with error: {:?}", e)
2664 }
2665 }
2666 }
2667
2668 #[test]
2669 fn test_generate_unique_id_uniqueness() {
2670 let id1 = generate_unique_id();
2671 let id2 = generate_unique_id();
2672 assert_ne!(id1, id2);
2673 }
2674
2675 #[test]
2676 fn test_try_create_selector_valid() {
2677 let selector = "div.class";
2678 let result = try_create_selector(selector);
2679 assert!(result.is_some());
2680 }
2681
2682 #[test]
2683 fn test_try_create_selector_invalid() {
2684 let selector = "div..class";
2685 let result = try_create_selector(selector);
2686 assert!(result.is_none());
2687 }
2688
2689 #[test]
2690 fn test_try_create_regex_valid() {
2691 let pattern = r"\d+";
2692 let result = try_create_regex(pattern);
2693 assert!(result.is_some());
2694 }
2695
2696 #[test]
2697 fn test_try_create_regex_invalid() {
2698 let pattern = r"\d+(";
2699 let result = try_create_regex(pattern);
2700 assert!(result.is_none());
2701 }
2702
2703 #[test]
2705 fn test_enhance_descriptions() {
2706 let builder =
2707 HtmlBuilder::new("<html><body></body></html>");
2708 let result = enhance_descriptions(builder);
2709 assert!(result.is_ok(), "Enhance descriptions failed");
2710 }
2711
2712 #[test]
2714 fn test_error_from_try_from_int_error() {
2715 let result: std::result::Result<u8, _> = i32::try_into(300); let err = result.unwrap_err(); let error: Error = Error::from(err);
2719
2720 if let Error::HtmlProcessingError { message, source } =
2721 error
2722 {
2723 assert_eq!(message, "Integer conversion error");
2724 assert!(source.is_some());
2725 } else {
2726 panic!("Expected HtmlProcessingError");
2727 }
2728 }
2729
2730 #[test]
2732 fn test_wcag_level_display() {
2733 assert_eq!(WcagLevel::A.to_string(), "A");
2734 assert_eq!(WcagLevel::AA.to_string(), "AA");
2735 assert_eq!(WcagLevel::AAA.to_string(), "AAA");
2736 }
2737
2738 #[test]
2740 fn test_check_keyboard_navigation() {
2741 let document =
2742 Html::parse_document("<a tabindex='-1'></a>");
2743 let mut issues = vec![];
2744 let result = AccessibilityReport::check_keyboard_navigation(
2745 &document,
2746 &mut issues,
2747 );
2748 assert!(result.is_ok());
2749 assert_eq!(issues.len(), 1);
2750 assert_eq!(
2751 issues[0].message,
2752 "Negative tabindex prevents keyboard focus"
2753 );
2754 }
2755
2756 #[test]
2758 fn test_check_language_attributes() {
2759 let document = Html::parse_document("<html></html>");
2760 let mut issues = vec![];
2761 let result = AccessibilityReport::check_language_attributes(
2762 &document,
2763 &mut issues,
2764 );
2765 assert!(result.is_ok());
2766 assert_eq!(issues.len(), 1);
2767 assert_eq!(
2768 issues[0].message,
2769 "Missing language declaration"
2770 );
2771 }
2772 }
2773
2774 mod missing_tests {
2775 use super::*;
2776 use std::collections::HashSet;
2777
2778 #[test]
2780 fn test_color_contrast_ratio() {
2781 let low_contrast = 2.5;
2782 let high_contrast = 7.1;
2783
2784 let config = AccessibilityConfig {
2785 min_contrast_ratio: 4.5,
2786 ..Default::default()
2787 };
2788
2789 assert!(
2790 low_contrast < config.min_contrast_ratio,
2791 "Low contrast should not pass"
2792 );
2793
2794 assert!(
2795 high_contrast >= config.min_contrast_ratio,
2796 "High contrast should pass"
2797 );
2798 }
2799
2800 #[test]
2802 fn test_dynamic_content_aria_attributes() {
2803 let html = r#"<div aria-live="polite"></div>"#;
2804 let cleaned_html = remove_invalid_aria_attributes(html);
2805 assert_eq!(
2806 cleaned_html, html,
2807 "Dynamic content ARIA attributes should be preserved"
2808 );
2809 }
2810
2811 #[test]
2813 fn test_strict_wcag_aaa_behavior() {
2814 let html = r#"<h1>Main Title</h1><h4>Skipped Level</h4>"#;
2815 let config = AccessibilityConfig {
2816 wcag_level: WcagLevel::AAA,
2817 ..Default::default()
2818 };
2819
2820 let report = validate_wcag(html, &config, None).unwrap();
2821 assert!(
2822 report.issue_count > 0,
2823 "WCAG AAA strictness should detect issues"
2824 );
2825
2826 let issue = &report.issues[0];
2827 assert_eq!(
2828 issue.issue_type,
2829 IssueType::LanguageDeclaration,
2830 "Expected heading structure issue"
2831 );
2832 }
2833
2834 #[test]
2836 fn test_large_html_performance() {
2837 let large_html =
2838 "<div>".repeat(1_000) + &"</div>".repeat(1_000);
2839 let result = validate_wcag(
2840 &large_html,
2841 &AccessibilityConfig::default(),
2842 None,
2843 );
2844 assert!(
2845 result.is_ok(),
2846 "Large HTML should not cause performance issues"
2847 );
2848 }
2849
2850 #[test]
2852 fn test_nested_elements_with_aria_attributes() {
2853 let html = r#"
2854 <div>
2855 <button aria-label="Test">Click</button>
2856 <nav aria-label="Main Navigation">
2857 <ul><li>Item 1</li></ul>
2858 </nav>
2859 </div>
2860 "#;
2861 let enhanced_html =
2862 add_aria_attributes(html, None).unwrap();
2863 assert!(
2864 enhanced_html.contains("aria-label"),
2865 "Nested elements should have ARIA attributes"
2866 );
2867 }
2868
2869 #[test]
2871 fn test_deeply_nested_headings() {
2872 let html = r#"
2873 <div>
2874 <h1>Main Title</h1>
2875 <div>
2876 <h3>Skipped Level</h3>
2877 </div>
2878 </div>
2879 "#;
2880 let mut issues = Vec::new();
2881 let document = Html::parse_document(html);
2882 check_heading_structure(&document, &mut issues);
2883
2884 assert!(
2885 issues.iter().any(|issue| issue.issue_type == IssueType::HeadingStructure),
2886 "Deeply nested headings with skipped levels should produce issues"
2887 );
2888 }
2889
2890 #[test]
2892 fn test_unique_id_long_runtime() {
2893 let ids: HashSet<_> =
2894 (0..10_000).map(|_| generate_unique_id()).collect();
2895 assert_eq!(
2896 ids.len(),
2897 10_000,
2898 "Generated IDs should be unique over long runtime"
2899 );
2900 }
2901
2902 #[test]
2904 fn test_custom_selector_failure() {
2905 let invalid_selector = "div..class";
2906 let result = try_create_selector(invalid_selector);
2907 assert!(
2908 result.is_none(),
2909 "Invalid selector should return None"
2910 );
2911 }
2912
2913 #[test]
2915 fn test_invalid_regex_pattern() {
2916 let invalid_pattern = r"\d+(";
2917 let result = try_create_regex(invalid_pattern);
2918 assert!(
2919 result.is_none(),
2920 "Invalid regex pattern should return None"
2921 );
2922 }
2923
2924 #[test]
2926 fn test_invalid_aria_attribute_removal() {
2927 let html = r#"<div aria-hidden="invalid"></div>"#;
2928 let cleaned_html = remove_invalid_aria_attributes(html);
2929 assert!(
2930 !cleaned_html.contains("aria-hidden"),
2931 "Invalid ARIA attributes should be removed"
2932 );
2933 }
2934
2935 #[test]
2937 fn test_invalid_selector() {
2938 let invalid_selector = "div..class";
2939 let result = try_create_selector(invalid_selector);
2940 assert!(result.is_none());
2941 }
2942
2943 #[test]
2945 fn test_issue_type_in_issue_struct() {
2946 let issue = Issue {
2947 issue_type: IssueType::MissingAltText,
2948 message: "Alt text is missing".to_string(),
2949 guideline: Some("WCAG 1.1.1".to_string()),
2950 element: Some("<img>".to_string()),
2951 suggestion: Some(
2952 "Add descriptive alt text".to_string(),
2953 ),
2954 };
2955 assert_eq!(issue.issue_type, IssueType::MissingAltText);
2956 }
2957
2958 #[test]
2960 fn test_add_aria_to_navs() {
2961 let html = "<nav>Main Navigation</nav>";
2962 let builder = HtmlBuilder::new(html);
2963 let result = add_aria_to_navs(builder).unwrap().build();
2964 assert!(result.contains(r#"aria-label="navigation""#));
2965 assert!(result.contains(r#"role="navigation""#));
2966 }
2967
2968 #[test]
2970 fn test_add_aria_to_forms() {
2971 let html = r#"<form>Form Content</form>"#;
2972 let result =
2973 add_aria_to_forms(HtmlBuilder::new(html)).unwrap();
2974 let content = result.build();
2975
2976 assert!(content.contains(r#"id="form-"#));
2977 assert!(content.contains(r#"aria-labelledby="form-"#));
2978 }
2979
2980 #[test]
2982 fn test_check_keyboard_navigation_click_handlers() {
2983 let html = r#"<button onclick="handleClick()"></button>"#;
2984 let document = Html::parse_document(html);
2985 let mut issues = vec![];
2986
2987 AccessibilityReport::check_keyboard_navigation(
2988 &document,
2989 &mut issues,
2990 )
2991 .unwrap();
2992
2993 assert!(
2994 issues.iter().any(|i| i.message == "Click handler without keyboard equivalent"),
2995 "Expected an issue for missing keyboard equivalents, but found: {:?}",
2996 issues
2997 );
2998 }
2999
3000 #[test]
3002 fn test_invalid_language_code() {
3003 let html = r#"<html lang="invalid-lang"></html>"#;
3004 let document = Html::parse_document(html);
3005 let mut issues = vec![];
3006 AccessibilityReport::check_language_attributes(
3007 &document,
3008 &mut issues,
3009 )
3010 .unwrap();
3011 assert!(issues
3012 .iter()
3013 .any(|i| i.message.contains("Invalid language code")));
3014 }
3015
3016 #[test]
3018 fn test_missing_required_aria_properties() {
3019 let html = r#"<div role="slider"></div>"#;
3020 let fragment = Html::parse_fragment(html);
3021 let element = fragment
3022 .select(&Selector::parse("div").unwrap())
3023 .next()
3024 .unwrap();
3025 let missing =
3026 get_missing_required_aria_properties(&element).unwrap();
3027 assert!(missing.contains(&"aria-valuenow".to_string()));
3028 }
3029
3030 #[test]
3032 fn test_invalid_regex_creation() {
3033 let invalid_pattern = "[unclosed";
3034 let regex = try_create_regex(invalid_pattern);
3035 assert!(
3036 regex.is_none(),
3037 "Invalid regex should return None"
3038 );
3039 }
3040
3041 #[test]
3043 fn test_invalid_selector_creation() {
3044 let invalid_selector = "div..class";
3045 let selector = try_create_selector(invalid_selector);
3046 assert!(
3047 selector.is_none(),
3048 "Invalid selector should return None"
3049 );
3050 }
3051
3052 #[test]
3054 fn test_add_aria_empty_buttons() {
3055 let html = r#"<button></button>"#;
3056 let builder = HtmlBuilder::new(html);
3057 let result = add_aria_to_buttons(builder).unwrap().build();
3058 assert!(
3059 result.contains("aria-label"),
3060 "ARIA label should be added to empty button"
3061 );
3062 }
3063
3064 #[test]
3066 fn test_wcag_aaa_validation() {
3067 let html = "<h1>Main Title</h1><h4>Skipped Heading</h4>";
3068 let config = AccessibilityConfig {
3069 wcag_level: WcagLevel::AAA,
3070 ..Default::default()
3071 };
3072 let report = validate_wcag(html, &config, None).unwrap();
3073 assert!(
3074 report.issue_count > 0,
3075 "WCAG AAA should detect issues"
3076 );
3077 }
3078
3079 #[test]
3081 fn test_unique_id_collisions() {
3082 let ids: HashSet<_> =
3083 (0..10_000).map(|_| generate_unique_id()).collect();
3084 assert_eq!(
3085 ids.len(),
3086 10_000,
3087 "Generated IDs should be unique"
3088 );
3089 }
3090
3091 #[test]
3093 fn test_add_aria_navigation() {
3094 let html = "<nav>Main Navigation</nav>";
3095 let builder = HtmlBuilder::new(html);
3096 let result = add_aria_to_navs(builder).unwrap().build();
3097 assert!(
3098 result.contains("aria-label"),
3099 "ARIA label should be added to navigation"
3100 );
3101 }
3102
3103 #[test]
3105 fn test_empty_html_handling() {
3106 let html = "";
3107 let result = add_aria_attributes(html, None);
3108 assert!(
3109 result.is_ok(),
3110 "Empty HTML should not cause errors"
3111 );
3112 assert_eq!(
3113 result.unwrap(),
3114 "",
3115 "Empty HTML should remain unchanged"
3116 );
3117 }
3118
3119 #[test]
3120 fn test_add_aria_to_inputs_with_different_types() {
3121 let html = r#"
3122 <input type="text" placeholder="Username">
3123 <input type="password" placeholder="Password">
3124 <input type="checkbox" id="remember">
3125 <input type="radio" name="choice">
3126 <input type="submit" value="Submit">
3127 <input type="unknown">
3128 "#;
3129
3130 let builder = HtmlBuilder::new(html);
3131 let result = add_aria_to_inputs(builder).unwrap().build();
3132
3133 assert!(!result.contains(r#"type="text".*aria-label"#));
3135 assert!(!result.contains(r#"type="password".*aria-label"#));
3136
3137 assert!(result.contains(
3139 r#"<label for="remember">Checkbox for remember</label>"#
3140 ));
3141
3142 assert!(result
3144 .contains(r#"<label for="option1">Option 1</label>"#));
3145
3146 assert!(!result.contains(r#"type="submit".*aria-label"#));
3148
3149 assert!(result.contains(r#"aria-label="unknown""#));
3151 }
3152
3153 #[test]
3154 fn test_has_associated_label() {
3155 let input = r#"<input type="text" id="username">"#;
3157 let html = r#"<label for="username">Username:</label>"#;
3158 assert!(has_associated_label(input, html));
3159
3160 let input = r#"<input type="text" id="username">"#;
3162 let html = r#"<label for="password">Password:</label>"#;
3163 assert!(!has_associated_label(input, html));
3164
3165 let input = r#"<input type="text">"#;
3167 let html = r#"<label for="username">Username:</label>"#;
3168 assert!(!has_associated_label(input, html));
3169 }
3170
3171 #[test]
3172 fn test_preserve_attributes() {
3173 let input = r#"<input type="text" class="form-control">"#;
3175 let result = preserve_attributes(input);
3176 assert!(result.contains("type=\"text\""));
3177 assert!(result.contains("class=\"form-control\""));
3178
3179 let input = r#"<input type="text">"#;
3181 let result = preserve_attributes(input);
3182 assert!(result.contains("type=\"text\""));
3183
3184 let input = r#"<input type='text'>"#;
3186 let result = preserve_attributes(input);
3187 assert!(result.contains("type='text'"));
3188
3189 let input = r#"<input required>"#;
3191 let result = preserve_attributes(input);
3192 assert!(result.contains("required"));
3193
3194 let input = "<input>";
3196 let result = preserve_attributes(input);
3197 assert!(
3198 result.contains("input"),
3199 "Should preserve the input tag name"
3200 );
3201
3202 let input = r#"<input name="test" value="multiple words">"#;
3204 let result = preserve_attributes(input);
3205 assert!(result.contains("name=\"test\""));
3206 assert!(result.contains("value=\"multiple words\""));
3207 }
3208
3209 #[test]
3210 fn test_preserve_attributes_with_data_attributes() {
3211 let input = r#"<input data-test="value" type="text">"#;
3213 let matches: Vec<_> = ATTRIBUTE_REGEX
3214 .captures_iter(input)
3215 .map(|cap| cap[0].to_string())
3216 .collect();
3217 println!("Actual matches: {:?}", matches);
3218
3219 let result = preserve_attributes(input);
3220 println!("Preserved attributes: {}", result);
3221 }
3222
3223 #[test]
3224 fn test_extract_input_type() {
3225 let input = r#"<input type="text" class="form-control">"#;
3227 assert_eq!(
3228 extract_input_type(input),
3229 Some("text".to_string())
3230 );
3231
3232 let input = r#"<input type='radio' name='choice'>"#;
3234 assert_eq!(
3235 extract_input_type(input),
3236 Some("radio".to_string())
3237 );
3238
3239 let input = r#"<input class="form-control">"#;
3241 assert_eq!(extract_input_type(input), None);
3242
3243 let input = r#"<input type="" class="form-control">"#;
3245 assert_eq!(extract_input_type(input), None); }
3247
3248 #[test]
3249 fn test_add_aria_to_inputs_with_existing_labels() {
3250 let html = r#"
3251 <input type="checkbox" id="existing">
3252 <label for="existing">Existing Label</label>
3253 <input type="radio" id="existing2">
3254 <label for="existing2">Existing Radio</label>
3255 "#;
3256
3257 let builder = HtmlBuilder::new(html);
3258 let result = add_aria_to_inputs(builder).unwrap().build();
3259
3260 assert!(!result.contains("aria-label"));
3262 assert_eq!(
3263 result.matches("<label").count(),
3264 2,
3265 "Should not add additional labels to elements that already have them"
3266 );
3267 }
3268
3269 #[test]
3270 fn test_add_aria_to_inputs_with_special_characters() {
3271 let html = r#"<input type="text" data-test="test's value" class="form & input">"#;
3272 let builder = HtmlBuilder::new(html);
3273 let result = add_aria_to_inputs(builder).unwrap().build();
3274
3275 assert!(result.contains("data-test=\"test's value\""));
3277 assert!(result.contains("class=\"form & input\""));
3278 }
3279
3280 #[test]
3281 fn test_toggle_button() {
3282 let original_html =
3283 r#"<button type="button">Menu</button>"#;
3284 let builder = HtmlBuilder::new(original_html);
3285 let enhanced_html =
3286 add_aria_to_buttons(builder).unwrap().build();
3287
3288 assert_eq!(
3290 enhanced_html,
3291 r#"<button aria-label="menu" type="button">Menu</button>"#,
3292 "The button should be enhanced with an aria-label"
3293 );
3294 }
3295
3296 #[test]
3297 fn test_replace_html_element_resilient_fallback() {
3298 let original = r#"<button disabled>Click</button>"#;
3299 let old_element = r#"<button disabled="">Click</button>"#;
3300 let new_element = r#"<button aria-disabled="true" disabled="">Click</button>"#;
3301
3302 let replaced = replace_html_element_resilient(
3303 original,
3304 old_element,
3305 new_element,
3306 );
3307
3308 assert!(replaced.contains(r#"aria-disabled="true""#), "Should replace with fallback even though original has disabled not disabled=\"\"");
3310 }
3311
3312 #[test]
3313 fn test_replace_html_element_resilient_no_match() {
3314 let original = r#"<div>Nothing to replace</div>"#;
3315 let old_element = r#"<button disabled="">Click</button>"#;
3316 let new_element = r#"<button aria-disabled="true" disabled="">Click</button>"#;
3317
3318 let replaced = replace_html_element_resilient(
3320 original,
3321 old_element,
3322 new_element,
3323 );
3324 assert_eq!(
3325 replaced, original,
3326 "No match means original stays unchanged"
3327 );
3328 }
3329
3330 #[test]
3331 fn test_normalize_shorthand_attributes_multiple() {
3332 let html = r#"<input disabled selected><button disabled>Press</button>"#;
3333 let normalized = normalize_shorthand_attributes(html);
3334 assert!(
3337 normalized
3338 .contains(r#"<input disabled="" selected="">"#),
3339 "Should expand both disabled and selected"
3340 );
3341 assert!(
3342 normalized.contains(r#"<button disabled="">"#),
3343 "Should expand the disabled attribute on the button"
3344 );
3345 }
3346
3347 #[test]
3348 fn test_remove_invalid_aria_attributes() {
3349 let html = r#"<div aria-hidden="invalid" aria-pressed="true"></div>"#;
3350 let cleaned = remove_invalid_aria_attributes(html);
3353 assert!(
3354 !cleaned.contains(r#"aria-hidden="invalid""#),
3355 "Invalid aria-hidden should be removed"
3356 );
3357 assert!(
3358 cleaned.contains(r#"aria-pressed="true""#),
3359 "Valid attribute should remain"
3360 );
3361 }
3362
3363 #[test]
3364 fn test_is_valid_aria_attribute_cases() {
3365 assert!(
3367 is_valid_aria_attribute("aria-label", "Submit"),
3368 "aria-label with non-empty string is valid"
3369 );
3370
3371 assert!(
3373 is_valid_aria_attribute("aria-pressed", "true"),
3374 "aria-pressed=\"true\" is valid"
3375 );
3376 assert!(
3377 is_valid_aria_attribute("aria-pressed", "false"),
3378 "aria-pressed=\"false\" is valid"
3379 );
3380 assert!(
3381 !is_valid_aria_attribute("aria-pressed", "yes"),
3382 "aria-pressed only allows true/false"
3383 );
3384
3385 assert!(
3387 !is_valid_aria_attribute(
3388 "aria-somethingrandom",
3389 "value"
3390 ),
3391 "Unknown ARIA attribute is invalid"
3392 );
3393 }
3394
3395 #[test]
3396 fn test_add_aria_to_accordions_basic() {
3397 let html = r#"
3398 <div class="accordion">
3399 <button>Section 1</button>
3400 <div>Content 1</div>
3401 <button>Section 2</button>
3402 <div>Content 2</div>
3403 </div>
3404 "#;
3405 let builder = HtmlBuilder::new(html);
3406 let result =
3407 add_aria_to_accordions(builder).unwrap().build();
3408
3409 assert!(
3411 result.contains(r#"aria-controls="section-1-content""#),
3412 "First accordion section should have aria-controls"
3413 );
3414 assert!(
3415 result.contains(r#"id="section-1-button""#),
3416 "First button should get an ID"
3417 );
3418 assert!(
3419 result.contains(r#"id="section-1-content""#),
3420 "First content should get an ID"
3421 );
3422 assert!(
3423 result.contains(r#"hidden"#),
3424 "Accordion content is hidden by default"
3425 );
3426 }
3427
3428 #[test]
3429 fn test_add_aria_to_accordions_empty() {
3430 let html = r#"<div class="accordion"></div>"#;
3431 let builder = HtmlBuilder::new(html);
3432 let result =
3433 add_aria_to_accordions(builder).unwrap().build();
3434
3435 assert!(result.contains(r#"class="accordion""#));
3437 }
3439
3440 #[test]
3441 fn test_add_aria_to_tabs_basic() {
3442 let html = r#"
3444 <div role="tablist">
3445 <button>Tab A</button>
3446 <button>Tab B</button>
3447 </div>
3448 "#;
3449 let builder = HtmlBuilder::new(html);
3450 let result = add_aria_to_tabs(builder).unwrap().build();
3451
3452 assert!(
3454 result.contains(
3455 r#"role="tab" id="tab1" aria-selected="true""#
3456 ),
3457 "First tab should be tab1, selected=true"
3458 );
3459 assert!(
3460 result.contains(
3461 r#"role="tab" id="tab2" aria-selected="false""#
3462 ),
3463 "Second tab should be tab2, selected=false"
3464 );
3465 assert!(
3467 result.contains(r#"aria-controls="panel1""#),
3468 "First tab controls panel1"
3469 );
3470 assert!(
3471 result.contains(r#"id="panel2" role="tabpanel""#),
3472 "Second tab panel should exist"
3473 );
3474 }
3475
3476 #[test]
3478 fn test_add_aria_to_tabs_no_tablist() {
3479 let html = r#"<div><button>Not a tab</button></div>"#;
3480 let builder = HtmlBuilder::new(html);
3481 let result = add_aria_to_tabs(builder).unwrap().build();
3482
3483 assert!(
3485 result.contains(r#"<button>Not a tab</button>"#),
3486 "Should remain unchanged"
3487 );
3488 assert!(!result.contains(r#"role="tab""#), "No transformation to role=tab if not inside role=tablist");
3489 }
3490
3491 #[test]
3493 fn test_count_checked_elements() {
3494 let html = r#"
3495 <html>
3496 <body>
3497 <div>
3498 <p>Paragraph</p>
3499 <span>Span</span>
3500 </div>
3501 </body>
3502 </html>
3503 "#;
3504 let document = Html::parse_document(html);
3505 let count = count_checked_elements(&document);
3506 assert!(
3510 count >= 5,
3511 "Expected at least 5 elements in the parsed tree"
3512 );
3513 }
3514
3515 #[test]
3516 fn test_check_language_attributes_valid() {
3517 let html = r#"<html lang="en"><body></body></html>"#;
3518 let document = Html::parse_document(html);
3519 let mut issues = vec![];
3520 let result =
3521 check_language_attributes(&document, &mut issues);
3522 assert!(result.is_ok());
3523 assert_eq!(issues.len(), 0, "No issues for valid lang");
3524 }
3525
3526 #[test]
3527 fn test_error_variants() {
3528 let _ = Error::InvalidAriaAttribute {
3529 attribute: "aria-bogus".to_string(),
3530 message: "Bogus attribute".to_string(),
3531 };
3532 let _ = Error::WcagValidationError {
3533 level: WcagLevel::AA,
3534 message: "Validation failed".to_string(),
3535 guideline: Some("WCAG 2.4.6".to_string()),
3536 };
3537 let _ = Error::HtmlTooLarge {
3538 size: 9999999,
3539 max_size: 1000000,
3540 };
3541 let _ = Error::HtmlProcessingError {
3542 message: "Something went wrong".to_string(),
3543 source: None,
3544 };
3545 let _ = Error::MalformedHtml {
3546 message: "Broken HTML".to_string(),
3547 fragment: None,
3548 };
3549 }
3550
3551 #[test]
3552 fn test_has_associated_label_no_id() {
3553 let input = r#"<input type="checkbox">"#;
3554 let html =
3555 r#"<label for="checkbox1">Checkbox Label</label>"#;
3556 assert!(
3558 !has_associated_label(input, html),
3559 "No ID => false"
3560 );
3561 }
3562
3563 #[test]
3564 fn test_generate_unique_id_format() {
3565 let new_id = generate_unique_id();
3566 assert!(
3568 new_id.starts_with("aria-"),
3569 "Generated ID should start with aria-"
3570 );
3571 }
3572
3573 #[test]
3574 fn test_add_aria_to_buttons_basic_button() {
3575 let html = r#"<button>Click me</button>"#;
3576 let builder = HtmlBuilder::new(html);
3577 let result = add_aria_to_buttons(builder).unwrap().build();
3578
3579 assert!(
3582 result.contains(r#"aria-label="click-me""#),
3583 "Should add aria-label for normal button text"
3584 );
3585 assert!(
3586 !result.contains(r#"aria-pressed=""#),
3587 "Should not add aria-pressed if not originally present"
3588 );
3589 }
3590
3591 #[test]
3592 fn test_add_aria_to_buttons_disabled() {
3593 let html = r#"<button disabled>Submit</button>"#;
3594 let builder = HtmlBuilder::new(html);
3595 let result = add_aria_to_buttons(builder).unwrap().build();
3596 assert!(
3599 result.contains(r#"aria-disabled="true""#),
3600 "Disabled button should have aria-disabled"
3601 );
3602 assert!(
3603 result.contains(r#"aria-label="submit""#),
3604 "Should have aria-label from button text"
3605 );
3606 assert!(
3608 !result.contains("aria-pressed"),
3609 "Disabled button shouldn't have aria-pressed"
3610 );
3611 }
3612
3613 #[test]
3614 fn test_add_aria_to_buttons_icon_span() {
3615 let html = r#"<button><span class="icon">🔍</span>Search</button>"#;
3616 let builder = HtmlBuilder::new(html);
3617 let result = add_aria_to_buttons(builder).unwrap().build();
3618
3619 assert!(
3622 result.contains(r#"left-pointing-magnifying-glass""#)
3623 );
3624 assert!(
3625 result.contains(
3626 r#"<span class="icon" aria-hidden="true">🔍</span>"#
3627 ),
3628 "Icon span should have aria-hidden=\"true\""
3629 );
3630 }
3631
3632 #[test]
3633 fn test_add_aria_to_buttons_toggle_flip() {
3634 let html = r#"<button aria-pressed="true">Toggle</button>"#;
3636 let builder = HtmlBuilder::new(html);
3637 let result = add_aria_to_buttons(builder).unwrap().build();
3638
3639 assert!(
3641 result.contains(r#"aria-pressed="false""#),
3642 "Existing aria-pressed=\"true\" should flip to false"
3643 );
3644 assert!(result.contains(r#"aria-label="toggle""#));
3646 }
3647
3648 #[test]
3649 fn test_add_aria_to_buttons_toggle_no_flip() {
3650 let html = r#"<button aria-pressed="true">On</button>"#;
3654 let builder = HtmlBuilder::new(html);
3659 let result = add_aria_to_buttons(builder).unwrap().build();
3660 assert!(result.contains(r#"aria-pressed="false""#));
3665 }
3666
3667 #[test]
3671 fn test_add_aria_to_toggle_no_aria_pressed() {
3672 let html = r#"<div class="toggle-button">Click me</div>"#;
3674 let builder = HtmlBuilder::new(html);
3675 let result = add_aria_to_toggle(builder).unwrap().build();
3676
3677 let doc = Html::parse_document(&result);
3679 let selector =
3680 Selector::parse("button.toggle-button").unwrap();
3681 let toggle = doc
3682 .select(&selector)
3683 .next()
3684 .expect("Should have button.toggle-button");
3685 assert_eq!(
3686 toggle.value().attr("aria-pressed"),
3687 Some("false")
3688 );
3689 assert_eq!(toggle.value().attr("role"), Some("button"));
3690 assert_eq!(toggle.inner_html().trim(), "Click me");
3691 }
3692
3693 #[test]
3694 fn test_add_aria_to_toggle_existing_aria_pressed() {
3695 let html = r#"<div class="toggle-button" aria-pressed="true">I'm on</div>"#;
3697 let builder = HtmlBuilder::new(html);
3698 let result = add_aria_to_toggle(builder).unwrap().build();
3699
3700 assert!(
3702 result.contains("toggle-button"),
3703 "Should preserve the toggle-button class"
3704 );
3705 assert!(
3706 result.contains("I'm on"),
3707 "Should preserve the content"
3708 );
3709 assert!(
3710 result.contains(r#"aria-pressed="true""#),
3711 "Should preserve aria-pressed value"
3712 );
3713 }
3714
3715 #[test]
3716 fn test_add_aria_to_toggle_preserves_other_attrs() {
3717 let html = r#"<div class="toggle-button" data-role="switch" style="color:red;" aria-pressed="false">Toggle</div>"#;
3718 let builder = HtmlBuilder::new(html);
3719 let result = add_aria_to_toggle(builder).unwrap().build();
3720
3721 assert!(
3723 result.contains(r#"class="toggle-button""#),
3724 "Should preserve class"
3725 );
3726 assert!(
3727 result.contains(r#"data-role="switch""#),
3728 "Should preserve data attribute"
3729 );
3730 assert!(
3731 result.contains(r#"style="color:red;""#),
3732 "Should preserve style"
3733 );
3734 assert!(
3735 result.contains(r#"aria-pressed="false""#),
3736 "Should preserve aria-pressed"
3737 );
3738 }
3739
3740 #[test]
3741 fn test_add_aria_to_toggle_no_toggle_elements() {
3742 let html = r#"<div>Just a regular div</div>"#;
3743 let builder = HtmlBuilder::new(html);
3744 let result = add_aria_to_toggle(builder).unwrap().build();
3745 assert_eq!(
3747 result, html,
3748 "No transformation if there's no .toggle-button"
3749 );
3750 }
3751
3752 #[test]
3753 fn test_has_alert_class_sets_alertdialog() -> Result<()> {
3754 let original_html = r#"
3756 <div class="modal alert">
3757 <div class="modal-content"><h2>Warning</h2><button>OK</button></div>
3758 </div>
3759 "#;
3760 let builder = HtmlBuilder {
3761 content: original_html.to_string(),
3762 };
3763
3764 let result = add_aria_to_modals(builder)?;
3765 let output = result.content;
3766
3767 assert!(
3769 output.contains(r#"role="alertdialog""#),
3770 "Expected role=\"alertdialog\" for .alert class"
3771 );
3772 assert!(
3773 output.contains(r#"aria-modal="true""#),
3774 "Expected aria-modal=\"true\" to be set"
3775 );
3776 Ok(())
3777 }
3778
3779 #[test]
3780 fn test_preserves_role_dialog() -> Result<()> {
3781 let original_html = r#"
3783 <div class="modal" role="dialog">
3784 <div class="modal-content"><button>Close</button></div>
3785 </div>
3786 "#;
3787 let builder = HtmlBuilder {
3788 content: original_html.to_string(),
3789 };
3790
3791 let result = add_aria_to_modals(builder)?;
3792 let output = result.content;
3793
3794 assert!(
3796 output.contains(r#"role="dialog""#),
3797 "Should preserve role=\"dialog\""
3798 );
3799 assert!(
3801 output.contains(r#"aria-modal="true""#),
3802 "Expected aria-modal=\"true\" to be added"
3803 );
3804 Ok(())
3805 }
3806
3807 #[test]
3808 fn test_preserves_role_alertdialog() -> Result<()> {
3809 let original_html = r#"
3811 <div class="modal" role="alertdialog">
3812 <div class="modal-content"><h2>Warning</h2></div>
3813 </div>
3814 "#;
3815 let builder = HtmlBuilder {
3816 content: original_html.to_string(),
3817 };
3818
3819 let result = add_aria_to_modals(builder)?;
3820 let output = result.content;
3821
3822 assert!(
3824 output.contains(r#"role="alertdialog""#),
3825 "Should preserve role=\"alertdialog\""
3826 );
3827 assert!(
3829 output.contains(r#"aria-modal="true""#),
3830 "Expected aria-modal=\"true\" to be added"
3831 );
3832 Ok(())
3833 }
3834
3835 #[test]
3836 fn test_already_has_aria_modal_does_not_duplicate() -> Result<()>
3837 {
3838 let original_html = r#"
3840 <div class="modal" role="dialog" aria-modal="true">
3841 <div class="modal-content"><button>Close</button></div>
3842 </div>
3843 "#;
3844 let builder = HtmlBuilder {
3845 content: original_html.to_string(),
3846 };
3847
3848 let result = add_aria_to_modals(builder)?;
3849 let output = result.content;
3850
3851 let count = output.matches(r#"aria-modal="true""#).count();
3854 assert_eq!(
3855 count, 1,
3856 "aria-modal=\"true\" should only appear once"
3857 );
3858 Ok(())
3859 }
3860
3861 #[test]
3862 fn test_adds_aria_describedby_for_dialog_description(
3863 ) -> Result<()> {
3864 let original_html = r#"
3866 <div class="modal">
3867 <div class="dialog-description">This is an important message</div>
3868 <div class="modal-content"><button>Close</button></div>
3869 </div>
3870 "#;
3871 let builder = HtmlBuilder {
3872 content: original_html.to_string(),
3873 };
3874
3875 let result = add_aria_to_modals(builder)?;
3876 let output = result.content;
3877
3878 assert!(
3880 output.contains(r#"role="dialog""#),
3881 "Expected role=\"dialog\""
3882 );
3883 assert!(
3884 output.contains(r#"aria-modal="true""#),
3885 "Expected aria-modal=\"true\""
3886 );
3887
3888 let has_aria_describedby =
3891 output.contains("aria-describedby=");
3892 assert!(
3893 has_aria_describedby,
3894 "Should have aria-describedby referencing the .dialog-description"
3895 );
3896 Ok(())
3897 }
3898
3899 #[test]
3900 fn test_dialog_description_missing_does_not_add_aria_describedby(
3901 ) -> Result<()> {
3902 let original_html = r#"
3904 <div class="modal">
3905 <div class="modal-content"><button>Close</button></div>
3906 </div>
3907 "#;
3908
3909 let builder = HtmlBuilder {
3910 content: original_html.to_string(),
3911 };
3912
3913 let result = add_aria_to_modals(builder)?;
3914 let output = result.content;
3915
3916 assert!(
3918 !output.contains("aria-describedby="),
3919 "Should not add aria-describedby if no descriptive element is found"
3920 );
3921 Ok(())
3922 }
3923
3924 #[test]
3925 fn test_paragraph_as_dialog_description() -> Result<()> {
3926 let original_html = r#"
3928 <div class="modal">
3929 <p>This is a brief description of the dialog.</p>
3930 <div class="modal-content"><button>Close</button></div>
3931 </div>
3932 "#;
3933
3934 let builder = HtmlBuilder {
3935 content: original_html.to_string(),
3936 };
3937
3938 let result = add_aria_to_modals(builder)?;
3939 let output = result.content;
3940
3941 assert!(
3945 output.contains("aria-describedby="),
3946 "Should have aria-describedby referencing the <p>"
3947 );
3948 assert!(
3949 output.contains("id=\"dialog-desc-"),
3950 "Should have auto-generated ID assigned to <p>"
3951 );
3952 Ok(())
3953 }
3954 }
3955}