Skip to main content

html_generator/
accessibility.rs

1// Copyright © 2025 HTML Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Accessibility-related functionality for HTML processing.
5//!
6//! This module provides comprehensive tools for improving HTML accessibility through:
7//! - Automated ARIA attribute management
8//! - WCAG 2.1 compliance validation
9//! - Accessibility issue detection and correction
10//!
11//! # WCAG Compliance
12//!
13//! This module implements checks for WCAG 2.1 compliance across three levels:
14//! - Level A (minimum level of conformance)
15//! - Level AA (addresses major accessibility barriers)
16//! - Level AAA (highest level of accessibility conformance)
17//!
18//! For detailed information about WCAG guidelines, see:
19//! <https://www.w3.org/WAI/WCAG21/quickref/>
20//!
21//! # Limitations
22//!
23//! While this module provides automated checks, some accessibility aspects require
24//! manual review, including:
25//! - Semantic correctness of ARIA labels
26//! - Meaningful alternative text for images
27//! - Logical heading structure
28//! - Color contrast ratios
29//!
30//! # Examples
31//!
32//! ```rust
33//! use html_generator::accessibility::{add_aria_attributes, validate_wcag, WcagLevel};
34//!
35//! use html_generator::accessibility::AccessibilityConfig;
36//! fn main() -> Result<(), Box<dyn std::error::Error>> {
37//!     let html = r#"<button>Click me</button>"#;
38//!
39//!     // Add ARIA attributes automatically
40//!     let enhanced_html = add_aria_attributes(html, None)?;
41//!
42//!     // Validate against WCAG AA level
43//!     let config = AccessibilityConfig::default();
44//!     validate_wcag(&enhanced_html, &config, None)?;
45//!
46//!     Ok(())
47//! }
48//! ```
49
50use 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
63/// Constants used throughout the accessibility module.
64///
65/// # Examples
66///
67/// ```
68/// use html_generator::accessibility::constants::{
69///     DEFAULT_NAV_ROLE, MAX_HTML_SIZE,
70/// };
71///
72/// assert_eq!(DEFAULT_NAV_ROLE, "navigation");
73/// assert!(MAX_HTML_SIZE > 0);
74/// ```
75pub mod constants {
76    /// Maximum size of HTML input in bytes (1MB).
77    ///
78    /// # Examples
79    ///
80    /// ```
81    /// use html_generator::accessibility::constants::MAX_HTML_SIZE;
82    /// assert_eq!(MAX_HTML_SIZE, 1_000_000);
83    /// ```
84    pub const MAX_HTML_SIZE: usize = 1_000_000;
85
86    /// Default ARIA role for navigation elements.
87    ///
88    /// # Examples
89    ///
90    /// ```
91    /// use html_generator::accessibility::constants::DEFAULT_NAV_ROLE;
92    /// assert_eq!(DEFAULT_NAV_ROLE, "navigation");
93    /// ```
94    pub const DEFAULT_NAV_ROLE: &str = "navigation";
95
96    /// Default ARIA role for buttons.
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use html_generator::accessibility::constants::DEFAULT_BUTTON_ROLE;
102    /// assert_eq!(DEFAULT_BUTTON_ROLE, "button");
103    /// ```
104    pub const DEFAULT_BUTTON_ROLE: &str = "button";
105
106    /// Default ARIA role for forms.
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use html_generator::accessibility::constants::DEFAULT_FORM_ROLE;
112    /// assert_eq!(DEFAULT_FORM_ROLE, "form");
113    /// ```
114    pub const DEFAULT_FORM_ROLE: &str = "form";
115
116    /// Default ARIA role for inputs.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use html_generator::accessibility::constants::DEFAULT_INPUT_ROLE;
122    /// assert_eq!(DEFAULT_INPUT_ROLE, "textbox");
123    /// ```
124    pub const DEFAULT_INPUT_ROLE: &str = "textbox";
125}
126
127/// Global counter for unique ID generation
128// static COUNTER: AtomicUsize = AtomicUsize::new(0);
129use constants::{DEFAULT_BUTTON_ROLE, DEFAULT_NAV_ROLE, MAX_HTML_SIZE};
130
131/// WCAG Conformance Levels.
132///
133/// # Examples
134///
135/// ```
136/// use html_generator::accessibility::WcagLevel;
137///
138/// assert_eq!(WcagLevel::AA.to_string(), "AA");
139/// ```
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
141pub enum WcagLevel {
142    /// Level A: Minimum level of conformance
143    /// Essential accessibility features that must be supported
144    A,
145
146    /// Level AA: Addresses major accessibility barriers
147    /// Standard level of conformance for most websites
148    AA,
149
150    /// Level AAA: Highest level of accessibility conformance
151    /// Includes additional enhancements and specialized features
152    AAA,
153}
154
155/// Types of accessibility issues that can be detected.
156///
157/// # Examples
158///
159/// ```
160/// use html_generator::accessibility::IssueType;
161///
162/// let kind = IssueType::MissingAltText;
163/// assert_eq!(format!("{kind:?}"), "MissingAltText");
164/// ```
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum IssueType {
167    /// Missing alternative text for images
168    MissingAltText,
169    /// Improper heading structure
170    HeadingStructure,
171    /// Missing form labels
172    MissingLabels,
173    /// Invalid ARIA attributes
174    InvalidAria,
175    /// Color contrast issues
176    ColorContrast,
177    /// Keyboard navigation issues
178    KeyboardNavigation,
179    /// Missing or invalid language declarations
180    LanguageDeclaration,
181}
182
183/// Enum to represent possible accessibility-related errors.
184///
185/// # Examples
186///
187/// ```
188/// use html_generator::accessibility::Error;
189///
190/// let err = Error::InvalidAriaAttribute {
191///     attribute: "aria-foo".into(),
192///     message: "unknown".into(),
193/// };
194/// assert!(err.to_string().contains("aria-foo"));
195/// ```
196#[derive(Debug, Error)]
197pub enum Error {
198    /// Error indicating an invalid ARIA attribute.
199    #[error("Invalid ARIA Attribute '{attribute}': {message}")]
200    InvalidAriaAttribute {
201        /// The name of the invalid attribute
202        attribute: String,
203        /// Description of the error
204        message: String,
205    },
206
207    /// Error indicating failure to validate HTML against WCAG guidelines.
208    #[error("WCAG {level} Validation Error: {message}")]
209    WcagValidationError {
210        /// WCAG conformance level where the error occurred
211        level: WcagLevel,
212        /// Description of the error
213        message: String,
214        /// Specific WCAG guideline reference
215        guideline: Option<String>,
216    },
217
218    /// Error indicating the HTML input is too large to process.
219    #[error(
220        "HTML Input Too Large: size {size} exceeds maximum {max_size}"
221    )]
222    HtmlTooLarge {
223        /// Actual size of the input
224        size: usize,
225        /// Maximum allowed size
226        max_size: usize,
227    },
228
229    /// Error indicating a failure in processing HTML for accessibility.
230    #[error("HTML Processing Error: {message}")]
231    HtmlProcessingError {
232        /// Description of the processing error
233        message: String,
234        /// Source of the error, if available
235        source: Option<Box<dyn std::error::Error + Send + Sync>>,
236    },
237
238    /// Error indicating malformed HTML input.
239    #[error("Malformed HTML: {message}")]
240    MalformedHtml {
241        /// Description of the HTML issue
242        message: String,
243        /// The problematic HTML fragment, if available
244        fragment: Option<String>,
245    },
246}
247
248/// Result type alias for accessibility operations.
249///
250/// # Examples
251///
252/// ```
253/// use html_generator::accessibility::{Error, Result};
254///
255/// fn check() -> Result<()> {
256///     Err(Error::HtmlTooLarge { size: 100, max_size: 50 })
257/// }
258/// assert!(check().is_err());
259/// ```
260pub type Result<T> = std::result::Result<T, Error>;
261
262/// Structure representing an accessibility issue found in the HTML.
263///
264/// # Examples
265///
266/// ```
267/// use html_generator::accessibility::{Issue, IssueType};
268///
269/// let issue = Issue {
270///     issue_type: IssueType::MissingAltText,
271///     message: "img has no alt".into(),
272///     guideline: Some("WCAG 1.1.1".into()),
273///     element: Some("<img src=\"x.png\">".into()),
274///     suggestion: Some("Add an alt attribute".into()),
275/// };
276/// assert_eq!(issue.issue_type, IssueType::MissingAltText);
277/// ```
278#[derive(Debug, Clone)]
279pub struct Issue {
280    /// Type of accessibility issue
281    pub issue_type: IssueType,
282    /// Description of the issue
283    pub message: String,
284    /// WCAG guideline reference, if applicable
285    pub guideline: Option<String>,
286    /// HTML element where the issue was found
287    pub element: Option<String>,
288    /// Suggested fix for the issue
289    pub suggestion: Option<String>,
290}
291
292/// Builds a [`Selector`] from a compile-time constant string.
293///
294/// Panics with a clear message if the selector fails to parse — every
295/// caller passes a literal, so a failure here is a bug, not a runtime
296/// condition.
297fn 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
303/// Builds a [`Regex`] from a compile-time constant string.
304///
305/// Panics with a clear message if the pattern fails to compile — every
306/// caller passes a literal, so a failure here is a bug.
307fn 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
313/// Static selectors for HTML elements and ARIA attributes
314static BUTTON_SELECTOR: Lazy<Selector> =
315    Lazy::new(|| try_create_selector("button:not([aria-label])"));
316
317/// Selector for navigation elements without ARIA attributes
318static NAV_SELECTOR: Lazy<Selector> =
319    Lazy::new(|| try_create_selector("nav:not([aria-label])"));
320
321/// Selector for form elements without ARIA attributes
322static FORM_SELECTOR: Lazy<Selector> =
323    Lazy::new(|| try_create_selector("form:not([aria-labelledby])"));
324
325/// Regex for finding input elements
326static INPUT_REGEX: Lazy<Regex> =
327    Lazy::new(|| try_create_regex(r"<input[^>]*>"));
328
329/// Comprehensive selector for all ARIA attributes
330static 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
343/// Selector for all button elements (used in tooltip processing)
344static ALL_BUTTON_SELECTOR: Lazy<Selector> =
345    Lazy::new(|| try_create_selector("button"));
346
347/// Selector for paragraph or dialog-description elements
348static DIALOG_DESC_SELECTOR: Lazy<Selector> =
349    Lazy::new(|| try_create_selector("p, .dialog-description"));
350
351/// Selector for the html element
352static HTML_SELECTOR: Lazy<Selector> =
353    Lazy::new(|| try_create_selector("html"));
354
355/// Selector for elements with a lang attribute
356static LANG_SELECTOR: Lazy<Selector> =
357    Lazy::new(|| try_create_selector("[lang]"));
358
359/// Selector for elements with a role attribute
360static ROLE_SELECTOR: Lazy<Selector> =
361    Lazy::new(|| try_create_selector("[role]"));
362
363/// Selector for interactive elements (keyboard navigation checks)
364static INTERACTIVE_SELECTOR: Lazy<Selector> = Lazy::new(|| {
365    try_create_selector(
366        "a, button, input, select, textarea, [tabindex]",
367    )
368});
369
370/// Regex for extracting id attributes from input tags
371static INPUT_ID_REGEX: Lazy<Regex> =
372    Lazy::new(|| try_create_regex(r#"id="([^"]+)""#));
373
374/// Set of valid ARIA attributes
375static 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
408/// Compiled regex for normalizing shorthand boolean attributes
409static 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
414/// Universal CSS selector for counting all elements
415static UNIVERSAL_SELECTOR: Lazy<Selector> = Lazy::new(|| {
416    Selector::parse("*").expect("Failed to compile universal selector")
417});
418
419/// Regex for extracting id="..." attribute values
420static ID_ATTR_REGEX: Lazy<Regex> = Lazy::new(|| {
421    Regex::new(r#"id="([^"]+)""#)
422        .expect("Failed to compile id attribute regex")
423});
424
425/// Color contrast requirements for different WCAG levels
426// static COLOR_CONTRAST_RATIOS: Lazy<HashMap<WcagLevel, f64>> = Lazy::new(|| {
427//     let mut m = HashMap::new();
428//     m.insert(WcagLevel::A, 3.0);       // Minimum contrast for Level A
429//     m.insert(WcagLevel::AA, 4.5);      // Enhanced contrast for Level AA
430//     m.insert(WcagLevel::AAA, 7.0);     // Highest contrast for Level AAA
431//     m
432// });
433///
434/// Set of elements that must have labels
435// static LABELABLE_ELEMENTS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
436//     [
437//         "input", "select", "textarea", "button", "meter",
438//         "output", "progress", "canvas"
439//     ].iter().copied().collect()
440// });
441///
442/// Selector for finding headings
443// static HEADING_SELECTOR: Lazy<Selector> = Lazy::new(|| {
444//     Selector::parse("h1, h2, h3, h4, h5, h6")
445//         .expect("Failed to create heading selector")
446// });
447///
448/// Selector for finding images
449// static IMAGE_SELECTOR: Lazy<Selector> = Lazy::new(|| {
450//     Selector::parse("img").expect("Failed to create image selector")
451// });
452/// Configuration for accessibility validation.
453///
454/// # Examples
455///
456/// ```
457/// use html_generator::accessibility::{AccessibilityConfig, WcagLevel};
458///
459/// let cfg = AccessibilityConfig {
460///     wcag_level: WcagLevel::AAA,
461///     ..Default::default()
462/// };
463/// assert_eq!(cfg.wcag_level, WcagLevel::AAA);
464/// ```
465#[derive(Debug, Copy, Clone)]
466pub struct AccessibilityConfig {
467    /// WCAG conformance level to validate against
468    pub wcag_level: WcagLevel,
469    /// Maximum allowed heading level jump (e.g., 1 means no skipping levels)
470    pub max_heading_jump: u8,
471    /// Minimum required color contrast ratio
472    pub min_contrast_ratio: f64,
473    /// Whether to automatically fix issues when possible
474    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, // WCAG AA standard
483            auto_fix: true,
484        }
485    }
486}
487
488/// A comprehensive accessibility check result.
489///
490/// # Examples
491///
492/// ```
493/// use html_generator::accessibility::{
494///     validate_wcag, AccessibilityConfig,
495/// };
496///
497/// let html = r#"<html lang="en"><body><h1>Hi</h1></body></html>"#;
498/// let report = validate_wcag(html, &AccessibilityConfig::default(), None).unwrap();
499/// assert_eq!(report.issue_count, report.issues.len());
500/// ```
501#[derive(Debug, Clone)]
502pub struct AccessibilityReport {
503    /// List of accessibility issues found
504    pub issues: Vec<Issue>,
505    /// WCAG conformance level checked
506    pub wcag_level: WcagLevel,
507    /// Total number of elements checked
508    pub elements_checked: usize,
509    /// Number of issues found
510    pub issue_count: usize,
511    /// Time taken for the check (in milliseconds)
512    pub check_duration_ms: u64,
513}
514
515/// Add ARIA attributes to HTML for improved accessibility.
516///
517/// This function performs a comprehensive analysis of the HTML content and adds
518/// appropriate ARIA attributes to improve accessibility. It handles:
519/// - Button labeling
520/// - Navigation landmarks
521/// - Form controls
522/// - Input elements
523/// - Dynamic content
524///
525/// # Arguments
526///
527/// * `html` - A string slice representing the HTML content
528/// * `config` - Optional configuration for the enhancement process
529///
530/// # Returns
531///
532/// * `Result<String>` - The modified HTML with ARIA attributes included
533///
534/// # Errors
535///
536/// Returns an error if:
537/// * The input HTML is larger than `MAX_HTML_SIZE`
538/// * The HTML cannot be parsed
539/// * There's an error adding ARIA attributes
540///
541/// # Examples
542///
543/// ```
544/// use html_generator::accessibility::add_aria_attributes;
545///
546/// // Empty buttons get a fallback `aria-label="button"`.
547/// let enhanced = add_aria_attributes("<button></button>", None).unwrap();
548/// assert!(enhanced.contains(r#"aria-label="button""#));
549/// ```
550pub 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    // Did the *input* already contain any `aria-…` attribute? If not,
564    // the final pipeline output's aria-* attributes are entirely our
565    // own additions, every one of which is in `VALID_ARIA_ATTRIBUTES`
566    // by construction (each `add_aria_to_*` only writes validated
567    // names). In that case the post-pipeline `remove_invalid_aria_attributes`
568    // sweep cannot find anything to strip and the html5ever parse
569    // it does to discover that is pure overhead. The byte-scan is
570    // SIMD-backed via stdlib `memchr`; ~10 ns vs. ~µs for a parse.
571    let input_had_aria = html.contains("aria-");
572
573    let mut html_builder = HtmlBuilder::new(html);
574
575    // Apply transformations
576    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        // Fast path: no user-supplied aria-* could have survived;
589        // every aria-* in `final_html` was emitted by us with a
590        // validated name. Skip the parse-and-sweep entirely.
591        return Ok(final_html);
592    }
593
594    // Slow path: input did have aria-* attributes, so the user could
595    // have supplied invalid ones (raw-HTML pass-through with
596    // `allow_unsafe_html: true`, or attributes preserved through the
597    // handler rewrites). Parse and strip.
598    Ok(remove_invalid_aria_attributes(&final_html))
599}
600
601/// A builder struct for constructing HTML content.
602#[derive(Debug, Clone)]
603struct HtmlBuilder {
604    content: String,
605}
606
607impl HtmlBuilder {
608    /// Creates a new `HtmlBuilder` with the given initial content.
609    fn new(initial_content: &str) -> Self {
610        HtmlBuilder {
611            content: initial_content.to_string(),
612        }
613    }
614
615    /// Builds the final HTML content.
616    fn build(self) -> String {
617        self.content
618    }
619}
620
621/// Helper function to count total elements checked during validation
622fn count_checked_elements(document: &Html) -> usize {
623    document.select(&UNIVERSAL_SELECTOR).count()
624}
625
626/// Check heading structure
627fn 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; // Skip checking if the selector is invalid
634        }
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
668/// Validate HTML against WCAG guidelines with detailed reporting.
669///
670/// Performs a comprehensive accessibility check based on WCAG guidelines and
671/// provides detailed feedback about any issues found.
672///
673/// # Arguments
674///
675/// * `html` - The HTML content to validate
676/// * `config` - Configuration options for the validation
677///
678/// # Returns
679///
680/// * `Result<AccessibilityReport>` - A detailed report of the accessibility check
681///
682/// # Examples
683///
684/// ```rust
685/// use html_generator::accessibility::{validate_wcag, AccessibilityConfig, WcagLevel};
686///
687/// fn main() -> Result<(), Box<dyn std::error::Error>> {
688///     let html = r#"<img src="test.jpg" alt="A descriptive alt text">"#;
689///     let config = AccessibilityConfig::default();
690///
691///     let report = validate_wcag(html, &config, None)?;
692///     println!("Found {} issues", report.issue_count);
693///
694///     Ok(())
695/// }
696/// ```
697pub fn validate_wcag(
698    html: &str,
699    config: &AccessibilityConfig,
700    disable_checks: Option<&[IssueType]>,
701) -> Result<AccessibilityReport> {
702    let start_time = std::time::Instant::now();
703    let mut issues = Vec::new();
704    let mut elements_checked = 0;
705
706    if html.trim().is_empty() {
707        return Ok(AccessibilityReport {
708            issues: Vec::new(),
709            wcag_level: config.wcag_level,
710            elements_checked: 0,
711            issue_count: 0,
712            check_duration_ms: 0,
713        });
714    }
715
716    let document = Html::parse_document(html);
717
718    if disable_checks
719        .map_or(true, |d| !d.contains(&IssueType::LanguageDeclaration))
720    {
721        AccessibilityReport::check_language_attributes(
722            &document,
723            &mut issues,
724        )?;
725    }
726
727    // This function returns `()`, so no `?`.
728    check_heading_structure(&document, &mut issues);
729
730    elements_checked += count_checked_elements(&document);
731
732    // Explicit error conversion for u64::try_from
733    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
751/// Converts an accessibility error into the library-wide
752/// [`crate::error::HtmlError`] so callers can use `?` across module
753/// boundaries without an explicit `.map_err`.
754///
755/// The mapping is lossy on purpose: it collapses structured data such as
756/// HTML fragments and WCAG conformance levels into the
757/// [`crate::error::HtmlError::Accessibility`] variant's `message`
758/// string. If you need the full structured error, match on the
759/// originating [`enum@Error`] directly before converting.
760///
761/// # Examples
762///
763/// ```
764/// use html_generator::accessibility::Error;
765/// use html_generator::error::HtmlError;
766///
767/// let acc_err = Error::InvalidAriaAttribute {
768///     attribute: "aria-foo".to_string(),
769///     message: "not a recognised attribute".to_string(),
770/// };
771/// let html_err: HtmlError = acc_err.into();
772/// assert!(matches!(html_err, HtmlError::Accessibility { .. }));
773/// ```
774impl 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
821/// From implementation for TryFromIntError
822impl 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
831/// Display implementation for WCAG levels
832impl 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
842/// Internal helper functions for accessibility checks
843impl AccessibilityReport {
844    /// Creates a new accessibility issue
845    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
863/// Regex for matching HTML tags
864static HTML_TAG_REGEX: Lazy<Regex> = Lazy::new(|| {
865    Regex::new(r"<[^>]*>").expect("static HTML_TAG_REGEX must compile")
866});
867
868/// Bundled emoji map — always available regardless of the working directory.
869///
870/// Uses `include_str!` under the hood to embed emoji data at compile time.
871/// An override via the `HTML_GENERATOR_EMOJI_DATA` environment variable is
872/// still supported for custom datasets.
873static 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
884/// Normalizes content for ARIA labels by removing HTML tags and converting to a standardized format.
885///
886/// # Arguments
887///
888/// * `content` - The content to normalize
889///
890/// # Returns
891///
892/// Returns a normalized string suitable for use as an ARIA label
893fn normalize_aria_label(content: &str) -> String {
894    // 1. Remove HTML
895    let no_html = HTML_TAG_REGEX.replace_all(content, "");
896    // 2. Trim
897    let text_only = no_html.trim();
898
899    // 3. If empty, fallback
900    if text_only.is_empty() {
901        return DEFAULT_BUTTON_ROLE.to_string();
902    }
903
904    // 4. Check each loaded emoji mapping
905    //    If the user input contains that emoji, return the mapped label
906    for (emoji, label) in EMOJI_MAP.iter() {
907        if text_only.contains(emoji.as_str()) {
908            return label.clone();
909        }
910    }
911
912    // 5. If no match, do your fallback normalization
913    text_only
914        .to_lowercase()
915        .replace(|c: char| !c.is_alphanumeric(), "-")
916        .replace("--", "-")
917        .trim_matches('-')
918        .to_string()
919}
920
921/// Adds ARIA attributes for buttons that have a `title="..."`, converting
922/// them into `<button aria-describedby="tooltip-n" ...>` and appending
923/// `<span id="tooltip-n" role="tooltip" hidden>title text</span>` right after.
924///
925/// If a button has no title or an empty title, we skip it.
926///
927/// If attribute ordering/spaces differ from `button.html()`, the substring approach
928/// may fail. For a fully robust solution, consider a DOM-based approach.
929fn add_aria_to_tooltips(
930    mut html_builder: HtmlBuilder,
931) -> Result<HtmlBuilder> {
932    // Fast path: no <button> with title= means no tooltips to add.
933    // `str::contains` is SIMD-backed memchr; ~10 ns vs. ~µs for a
934    // full html5ever parse.
935    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
990/// Enhances "toggle" buttons with ARIA attributes indicating toggled state.
991///
992/// - Looks for elements with a `.toggle-button` class.
993/// - Ensures `aria-pressed="true|false"` is set. Defaults to "false" if missing.
994/// - Optionally sets `role="button"`, if you want standard button semantics.
995/// - Preserves other attributes on the element.
996///
997/// Example input HTML:
998/// ```html
999/// <div class="toggle-button">Toggle me</div>
1000/// <div class="toggle-button" aria-pressed="true">I'm on</div>
1001/// ```
1002///
1003/// After running:
1004/// ```html
1005/// <button class="toggle-button" role="button" aria-pressed="false">Toggle me</button>
1006/// <button class="toggle-button" role="button" aria-pressed="true">I'm on</button>
1007/// ```
1008fn add_aria_to_toggle(
1009    mut html_builder: HtmlBuilder,
1010) -> Result<HtmlBuilder> {
1011    // Fast path: no `.toggle-button` class anywhere → nothing to do.
1012    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
1059/// Adds ARIA attributes to button elements.
1060///
1061/// Handles:
1062/// - Adding `aria-disabled="true"` for buttons with the `disabled` attribute.
1063/// - Adding `aria-pressed="false"` for non-disabled toggle buttons.
1064/// - Ensures `aria-label` is present for all buttons.
1065///
1066/// # Arguments
1067///
1068/// * `html_builder` - The HTML builder containing the content to process.
1069///
1070/// # Returns
1071///
1072/// * `Result<HtmlBuilder>` - The processed HTML builder with added ARIA attributes.
1073fn add_aria_to_buttons(
1074    mut html_builder: HtmlBuilder,
1075) -> Result<HtmlBuilder> {
1076    // Fast path: no <button> tag → no work.
1077    if !html_builder.content.contains("<button") {
1078        return Ok(html_builder);
1079    }
1080
1081    let document = Html::parse_fragment(&html_builder.content);
1082
1083    // Our selector targets <button> elements lacking an aria-label
1084    {
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            // 1) Add aria-hidden="true" to any <span class="icon">
1093            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            // 2) Build new attribute list
1101            let mut attributes = Vec::new();
1102
1103            // If disabled => aria-disabled="true"
1104            if button.value().attr("disabled").is_some() {
1105                attributes.push(r#"aria-disabled="true""#.to_string());
1106            } else {
1107                // If the button has aria-pressed => it's a toggle button
1108                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            // 3) Ensure `aria-label` is present
1124            if aria_label.is_empty() {
1125                aria_label = "button".to_string();
1126            }
1127            attributes.push(format!(r#"aria-label="{}""#, aria_label));
1128
1129            // 4) Preserve existing attributes (except flipping or re-adding aria-pressed)
1130            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            // 5) Generate the new button HTML
1138            let new_button_html = format!(
1139                "<button {}>{}</button>",
1140                attributes.join(" "),
1141                inner_content
1142            );
1143
1144            // 6) Queue the replacement (applied in batch)
1145            replacer.push(&original_button_html, &new_button_html);
1146        }
1147
1148        html_builder.content = replacer.apply();
1149    }
1150
1151    Ok(html_builder)
1152}
1153
1154/// Collects element replacements and applies them in reverse byte-offset
1155/// order, ensuring duplicate identical snippets are matched correctly and
1156/// earlier replacements never invalidate later byte positions.
1157///
1158/// This is the DOM-aware replacement strategy: callers select elements via
1159/// `scraper`, build new HTML for each, and push `(old, new)` pairs into
1160/// this collector.  The final `apply()` call resolves each old snippet to
1161/// its byte offset in the document (using sequential search so the Nth
1162/// duplicate maps to the Nth occurrence) and patches them back-to-front.
1163struct DomReplacer {
1164    /// Source document HTML that will be patched.
1165    source: String,
1166    /// Queued replacements: `(old_html, new_html)`.
1167    /// Order matters — pairs are matched to occurrences in document order.
1168    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    /// Resolve each queued replacement to a byte range in `self.source`,
1185    /// then apply all replacements from the end of the document backwards
1186    /// so that earlier byte offsets remain valid.
1187    fn apply(self) -> String {
1188        // Phase 1: locate byte offsets.  We walk the queue in document
1189        // order.  For each `old_html` we search forward from the last
1190        // match position of the *same* needle so that two identical
1191        // snippets resolve to their first and second occurrences
1192        // respectively.
1193        let mut resolved: Vec<(usize, usize, String)> = Vec::new();
1194        // Track the next search start per unique old_html pattern so
1195        // duplicate identical elements resolve correctly.
1196        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            // Try direct match first
1205            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            // Fallback: normalize shorthand boolean attributes
1216            let normalized_old = SHORTHAND_ATTR_REGEX.replace_all(
1217                old_html,
1218                |caps: &regex::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: &regex::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                // The byte offset in the normalized slice may differ from
1233                // the original.  Re-scan the original to find the
1234                // opening tag at approximately the same position.
1235                let approx_abs = search_start + pos;
1236                // Use the opening tag name to anchor into the original
1237                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                    // Find the matching close in the original source
1243                    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                // If anchoring failed, use approximate offset
1257                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            // Last resort: first occurrence from search_start via replacen
1272            // (preserves old behaviour for edge cases)
1273            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            // If nothing matched at all, skip this replacement silently
1282        }
1283
1284        // Phase 2: sort by offset descending, then apply.
1285        // `sort_by_key(Reverse)` is what clippy 1.95+ asks for in
1286        // place of a manual comparator closure.
1287        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
1301/// Extract the opening tag string (e.g. `<button` from `<button id="x">...`)
1302fn 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
1313/// Finds the end byte offset of a complete element starting at `start` in
1314/// `source`, using the closing tag from `element_html` as reference.
1315fn find_element_end(
1316    source: &str,
1317    start: usize,
1318    element_html: &str,
1319) -> Option<usize> {
1320    // Determine the tag name from element_html
1321    let tag_name = extract_opening_tag(element_html)
1322        .trim_start_matches('<')
1323        .to_string();
1324
1325    // Look for the closing tag after start
1326    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        // Self-closing or void element — find the end of the opening tag
1331        source[start..].find('>').map(|p| start + p + 1)
1332    }
1333}
1334
1335/// Replaces an HTML element in the document content.
1336///
1337/// Uses normalized comparison to handle attribute ordering and
1338/// whitespace differences between the source HTML and the parsed
1339/// element representation.  For batch replacements of multiple
1340/// elements (especially duplicate identical snippets), prefer
1341/// [`DomReplacer`].
1342fn 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: &regex::Captures| {
1356            let attr = &caps[1]; // e.g. "disabled"
1357            let delim = &caps[2]; // e.g. ">" or " "
1358
1359            // Insert ="" right before the delimiter
1360            // So <button disabled> becomes <button disabled="">
1361            // but <button disabled=""> won't match, so remains as-is
1362            format!(r#"{}=""{}"#, attr, delim)
1363        })
1364        .to_string()
1365}
1366
1367/// Add ARIA attributes to navigation elements.
1368fn add_aria_to_navs(
1369    mut html_builder: HtmlBuilder,
1370) -> Result<HtmlBuilder> {
1371    // Fast path: no <nav> tag in the source.
1372    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
1399/// Add ARIA attributes to form elements.
1400fn add_aria_to_forms(
1401    mut html_builder: HtmlBuilder,
1402) -> Result<HtmlBuilder> {
1403    // Fast path: no <form> tag in the source.
1404    if !html_builder.content.contains("<form") {
1405        return Ok(html_builder);
1406    }
1407
1408    let document = Html::parse_fragment(&html_builder.content);
1409
1410    // Traverse form elements and add ARIA attributes
1411    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        // Deterministic per-invocation id — stable across runs for the
1417        // same input, which keeps ETag/snapshot caching honest.
1418        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        // Add id attribute if missing
1424        if !attributes.iter().any(|&(k, _)| k == "id") {
1425            attributes.push(("id", &*form_id));
1426        }
1427
1428        // Add aria-labelledby attribute if missing
1429        if !attributes.iter().any(|&(k, _)| k == "aria-labelledby") {
1430            attributes.push(("aria-labelledby", &*form_id));
1431        }
1432
1433        // Build the replacement form element
1434        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
1451/// Enhance tab controls with ARIA attributes.
1452///
1453/// This function expects:
1454/// - A container element (e.g., <div class="tab-container">) that encloses .tab-button elements.
1455/// - Each .tab-button will become role="tab".
1456/// - The container will become role="tablist" if matched.
1457/// - We'll set aria-controls on each .tab-button, pointing to a <div id="panelX" role="tabpanel"> (not shown here, but recommended).
1458/// - We'll assume the first tab is active by default; the rest are aria-selected="false".
1459///
1460/// Adjust selectors and logic as needed for your codebase.
1461fn add_aria_to_tabs(
1462    mut html_builder: HtmlBuilder,
1463) -> Result<HtmlBuilder> {
1464    // Fast path: no `role='tablist'` literal in the source.
1465    // (`role="tablist"` with double-quotes is also accepted.)
1466    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
1523/// Ensures that modal dialogs have `role="dialog"` (if not alert/confirmation)
1524/// and `aria-modal="true"` for proper screen reader handling.
1525///
1526/// This function looks for elements with `class="modal"`.
1527/// - If `role` is missing or is an empty string, we add `role="dialog"`.
1528/// - If it's an alert/alertdialog, we *leave* it alone.
1529/// - If `aria-modal` is missing, we add `aria-modal="true"`.
1530fn add_aria_to_modals(
1531    mut html_builder: HtmlBuilder,
1532) -> Result<HtmlBuilder> {
1533    // Fast path: no `modal` substring → no `.modal` elements.
1534    if !html_builder.content.contains("modal") {
1535        return Ok(html_builder);
1536    }
1537
1538    // 1) Parse with parse_fragment to avoid automatic <html> or <body> insertion
1539    let document = Html::parse_fragment(&html_builder.content);
1540
1541    // 2) Create a selector to find `.modal` elements
1542    let modal_selector = match Selector::parse(".modal") {
1543        Ok(s) => s,
1544        Err(_) => {
1545            return Ok(html_builder); // If selector fails, just return original
1546        }
1547    };
1548
1549    // 3) For each `.modal` in the parsed fragment — batch replacements
1550    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        // Check for a descriptive paragraph or .dialog-description
1598        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    // Fast path: no `accordion` class string → no .accordion elements.
1673    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
1719/// Add ARIA attributes to input elements.
1720fn add_aria_to_inputs(
1721    mut html_builder: HtmlBuilder,
1722) -> Result<HtmlBuilder> {
1723    // Fast path: no <input tag → regex has nothing to find.
1724    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        // Find all <input> tags via the regex
1732        for cap in INPUT_REGEX.captures_iter(&html_builder.content) {
1733            let input_tag = &cap[0];
1734
1735            // If there's already an associated label or aria-label, skip
1736            if input_tag.contains("aria-label")
1737                || has_associated_label(
1738                    input_tag,
1739                    &html_builder.content,
1740                )
1741            {
1742                continue;
1743            }
1744
1745            // Determine the input type
1746            let input_type = extract_input_type(input_tag)
1747                .unwrap_or_else(|| "text".to_string());
1748
1749            match input_type.as_str() {
1750                // Skip text-like and other input types that have visible labels or are not labelable
1751                "text" | "search" | "tel" | "url" | "email"
1752                | "password" | "hidden" | "submit" | "reset"
1753                | "button" | "image" => {
1754                    // Do nothing
1755                }
1756
1757                // For checkbox/radio, ensure ID + label, avoiding duplicates
1758                "checkbox" | "radio" => {
1759                    // Preserve all existing attributes
1760                    let attributes = preserve_attributes(input_tag);
1761
1762                    // 1) Check if there's already an id="..." in the attributes
1763                    if let Some(id_match) =
1764                        ID_ATTR_REGEX.captures(&attributes)
1765                    {
1766                        // Already has an ID, so just use it—no duplicates
1767                        let existing_id = &id_match[1];
1768                        // Also remove the old id= from the attribute string
1769                        // so we only insert it once in the final <input ...>
1770                        let attributes_no_id = ID_ATTR_REGEX
1771                            .replace(&attributes, "")
1772                            .to_string();
1773
1774                        // Decide the label text
1775                        let label_text = if input_type == "checkbox" {
1776                            format!("Checkbox for {}", existing_id)
1777                        } else {
1778                            "Option".to_string()
1779                        };
1780
1781                        // Reconstruct <input> with a single id="existingId" + label
1782                        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                        // No ID found => generate a new one
1795                        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                // For any other types, automatically add `aria-label` with the type name
1815                _ => {
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        // Perform all replacements via batch DOM-aware replacer
1828        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
1838// Helper function to check for associated labels (using string manipulation)
1839fn has_associated_label(input_tag: &str, html_content: &str) -> bool {
1840    if let Some(id_match) = INPUT_ID_REGEX.captures(input_tag) {
1841        let id = &id_match[1];
1842        Regex::new(&format!(
1843            r#"<label\s+for="{}"\s*>"#,
1844            regex::escape(id)
1845        ))
1846        .is_ok_and(|r| r.is_match(html_content))
1847    } else {
1848        false
1849    }
1850}
1851
1852// Regex to capture all key-value pairs in the tag
1853static ATTRIBUTE_REGEX: Lazy<Regex> = Lazy::new(|| {
1854    try_create_regex(
1855        r#"(?:data-\w+|[a-zA-Z]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\S+))?"#,
1856    )
1857});
1858
1859/// Extract and preserve existing attributes from an input tag.
1860fn preserve_attributes(input_tag: &str) -> String {
1861    ATTRIBUTE_REGEX
1862        .captures_iter(input_tag)
1863        .map(|cap| cap[0].to_string())
1864        .collect::<Vec<String>>()
1865        .join(" ")
1866}
1867
1868/// Extract input type from an input tag.
1869fn extract_input_type(input_tag: &str) -> Option<String> {
1870    static TYPE_REGEX: Lazy<Regex> = Lazy::new(|| {
1871        Regex::new(r#"type=["']([^"']+)["']"#)
1872            .expect("static TYPE_REGEX must compile")
1873    });
1874
1875    TYPE_REGEX
1876        .captures(input_tag)
1877        .and_then(|cap| cap.get(1))
1878        .map(|m| m.as_str().to_string())
1879}
1880
1881/// Generate a unique ID prefixed with `aria-`.
1882///
1883/// Uses a process-wide monotonic counter instead of a random UUID so the
1884/// output is reproducible across runs of identical input. Retained as a
1885/// test helper only — pipeline functions ([`add_aria_to_forms`],
1886/// [`add_aria_to_modals`]) own their own per-invocation counters so that
1887/// `add_aria_attributes(x) == add_aria_attributes(x)` byte-for-byte.
1888#[cfg(test)]
1889fn generate_unique_id() -> String {
1890    use std::sync::atomic::{AtomicU64, Ordering};
1891    static COUNTER: AtomicU64 = AtomicU64::new(0);
1892    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
1893    format!("aria-{n}")
1894}
1895
1896fn remove_invalid_aria_attributes(html: &str) -> String {
1897    // Fast path: the overwhelmingly common case is that every aria-*
1898    // attribute is already valid. In that case the function should
1899    // touch neither the allocator nor the document tree.
1900    let document = Html::parse_fragment(html);
1901    let mut replacer: Option<DomReplacer> = None;
1902
1903    for element in document.select(&ARIA_SELECTOR) {
1904        // Skip elements with no invalid attrs before touching memory.
1905        let has_invalid = element.value().attrs().any(|(n, v)| {
1906            n.starts_with("aria-") && !is_valid_aria_attribute(n, v)
1907        });
1908        if !has_invalid {
1909            continue;
1910        }
1911
1912        let element_html = element.html();
1913        let mut updated_html = element_html.clone();
1914        for (attr_name, attr_value) in element.value().attrs() {
1915            if attr_name.starts_with("aria-")
1916                && !is_valid_aria_attribute(attr_name, attr_value)
1917            {
1918                updated_html = updated_html.replace(
1919                    &format!(r#" {}="{}""#, attr_name, attr_value),
1920                    "",
1921                );
1922            }
1923        }
1924
1925        replacer
1926            .get_or_insert_with(|| DomReplacer::new(html))
1927            .push(&element_html, &updated_html);
1928    }
1929
1930    // If no replacer was allocated, no invalid attrs were found —
1931    // the original string is already the correct output.
1932    match replacer {
1933        Some(r) => r.apply(),
1934        None => html.to_string(),
1935    }
1936}
1937
1938/// Check if an ARIA attribute is valid.
1939fn is_valid_aria_attribute(name: &str, value: &str) -> bool {
1940    if !VALID_ARIA_ATTRIBUTES.contains(name) {
1941        return false; // Invalid ARIA attribute name
1942    }
1943
1944    match name {
1945        "aria-hidden" | "aria-expanded" | "aria-pressed"
1946        | "aria-invalid" => {
1947            matches!(value, "true" | "false") // Only "true" or "false" are valid
1948        }
1949        // Note: an `aria-level` arm was removed — `aria-level` is
1950        // not in `VALID_ARIA_ATTRIBUTES`, so the early `contains`
1951        // check rejects it before this match runs. Re-add both the
1952        // whitelist entry and a dedicated arm together if WCAG
1953        // heading-level support is needed.
1954        _ => !value.trim().is_empty(), // General check for non-empty values
1955    }
1956}
1957
1958/// Helper functions for WCAG validation
1959impl AccessibilityReport {
1960    /// Check keyboard navigation.
1961    ///
1962    /// Walks all interactive elements (`a`, `button`, `input`, `select`,
1963    /// `textarea`, `[tabindex]`) and pushes a `KeyboardNavigation`
1964    /// [`Issue`] for negative tabindex values or click handlers without
1965    /// keyboard equivalents.
1966    ///
1967    /// # Examples
1968    ///
1969    /// ```
1970    /// use html_generator::accessibility::{AccessibilityReport, Issue};
1971    /// use scraper::Html;
1972    ///
1973    /// let doc = Html::parse_document(
1974    ///     r#"<html><body><button tabindex="-1">x</button></body></html>"#,
1975    /// );
1976    /// let mut issues: Vec<Issue> = Vec::new();
1977    /// AccessibilityReport::check_keyboard_navigation(&doc, &mut issues).unwrap();
1978    /// assert!(issues.iter().any(|i| i.message.contains("Negative tabindex")));
1979    /// ```
1980    ///
1981    /// # Errors
1982    ///
1983    /// Returns [`Error::HtmlProcessingError`] if internal selector
1984    /// dispatch fails (none of the static selectors used here can
1985    /// fail in practice).
1986    pub fn check_keyboard_navigation(
1987        document: &Html,
1988        issues: &mut Vec<Issue>,
1989    ) -> Result<()> {
1990        let interactive_elements =
1991            document.select(&INTERACTIVE_SELECTOR);
1992
1993        for element in interactive_elements {
1994            // Check tabindex
1995            if let Some(tabindex) = element.value().attr("tabindex") {
1996                if let Ok(index) = tabindex.parse::<i32>() {
1997                    if index < 0 {
1998                        issues.push(Issue {
1999                        issue_type: IssueType::KeyboardNavigation,
2000                        message: "Negative tabindex prevents keyboard focus".to_string(),
2001                        guideline: Some("WCAG 2.1.1".to_string()),
2002                        element: Some(element.html()),
2003                        suggestion: Some("Remove negative tabindex value".to_string()),
2004                    });
2005                    }
2006                }
2007            }
2008
2009            // Check for click handlers without keyboard equivalents
2010            if element.value().attr("onclick").is_some()
2011                && element.value().attr("onkeypress").is_none()
2012                && element.value().attr("onkeydown").is_none()
2013            {
2014                issues.push(Issue {
2015                    issue_type: IssueType::KeyboardNavigation,
2016                    message:
2017                        "Click handler without keyboard equivalent"
2018                            .to_string(),
2019                    guideline: Some("WCAG 2.1.1".to_string()),
2020                    element: Some(element.html()),
2021                    suggestion: Some(
2022                        "Add keyboard event handlers".to_string(),
2023                    ),
2024                });
2025            }
2026        }
2027        Ok(())
2028    }
2029
2030    /// Check language attributes.
2031    ///
2032    /// Verifies that the root `<html>` element carries a `lang` attribute
2033    /// and that any element-level `lang="..."` overrides are valid BCP 47
2034    /// codes. Each violation is pushed to `issues`.
2035    ///
2036    /// # Examples
2037    ///
2038    /// ```
2039    /// use html_generator::accessibility::{AccessibilityReport, Issue};
2040    /// use scraper::Html;
2041    ///
2042    /// let doc = Html::parse_document(r#"<html><body><p>x</p></body></html>"#);
2043    /// let mut issues: Vec<Issue> = Vec::new();
2044    /// AccessibilityReport::check_language_attributes(&doc, &mut issues).unwrap();
2045    /// assert!(issues.iter().any(|i| i.message.contains("Missing language")));
2046    /// ```
2047    ///
2048    /// # Errors
2049    ///
2050    /// Returns [`Error::HtmlProcessingError`] for internal selector
2051    /// dispatch failures (not reachable in practice).
2052    pub fn check_language_attributes(
2053        document: &Html,
2054        issues: &mut Vec<Issue>,
2055    ) -> Result<()> {
2056        // Check html lang attribute
2057        let html_element = document.select(&HTML_SELECTOR).next();
2058        if let Some(element) = html_element {
2059            if element.value().attr("lang").is_none() {
2060                Self::add_issue(
2061                    issues,
2062                    IssueType::LanguageDeclaration,
2063                    "Missing language declaration",
2064                    Some("WCAG 3.1.1".to_string()),
2065                    Some(element.html()),
2066                    Some(
2067                        "Add lang attribute to html element"
2068                            .to_string(),
2069                    ),
2070                );
2071            }
2072        }
2073
2074        // Check for changes in language
2075        let text_elements = document.select(&LANG_SELECTOR);
2076        for element in text_elements {
2077            if let Some(lang) = element.value().attr("lang") {
2078                if !is_valid_language_code(lang) {
2079                    Self::add_issue(
2080                        issues,
2081                        IssueType::LanguageDeclaration,
2082                        format!("Invalid language code: {}", lang),
2083                        Some("WCAG 3.1.2".to_string()),
2084                        Some(element.html()),
2085                        Some(
2086                            "Use valid BCP 47 language code"
2087                                .to_string(),
2088                        ),
2089                    );
2090                }
2091            }
2092        }
2093        Ok(())
2094    }
2095
2096    /// Check advanced ARIA usage.
2097    ///
2098    /// Verifies that any `role="..."` attributes are valid for the
2099    /// element they appear on, and that elements with ARIA roles carry
2100    /// all required companion properties (e.g. a `slider` requires
2101    /// `aria-valuenow`).
2102    ///
2103    /// # Examples
2104    ///
2105    /// ```
2106    /// use html_generator::accessibility::{AccessibilityReport, Issue};
2107    /// use scraper::Html;
2108    ///
2109    /// // The `aria-label` is what makes the element match the
2110    /// // `aria-*` selector that `check_advanced_aria` walks; `slider`
2111    /// // requires `aria-valuenow` plus min/max, which are absent here.
2112    /// let doc = Html::parse_fragment(
2113    ///     r#"<div role="slider" aria-label="vol"></div>"#,
2114    /// );
2115    /// let mut issues: Vec<Issue> = Vec::new();
2116    /// AccessibilityReport::check_advanced_aria(&doc, &mut issues).unwrap();
2117    /// assert!(
2118    ///     issues
2119    ///         .iter()
2120    ///         .any(|i| i.message.contains("required ARIA properties"))
2121    /// );
2122    /// ```
2123    ///
2124    /// # Errors
2125    ///
2126    /// Returns [`Error::HtmlProcessingError`] for internal selector
2127    /// dispatch failures (not reachable in practice).
2128    pub fn check_advanced_aria(
2129        document: &Html,
2130        issues: &mut Vec<Issue>,
2131    ) -> Result<()> {
2132        // Check for proper ARIA roles
2133        for element in document.select(&ROLE_SELECTOR) {
2134            if let Some(role) = element.value().attr("role") {
2135                if !is_valid_aria_role(role, &element) {
2136                    Self::add_issue(
2137                        issues,
2138                        IssueType::InvalidAria,
2139                        format!(
2140                            "Invalid ARIA role '{}' for element",
2141                            role
2142                        ),
2143                        Some("WCAG 4.1.2".to_string()),
2144                        Some(element.html()),
2145                        Some("Use appropriate ARIA role".to_string()),
2146                    );
2147                }
2148            }
2149        }
2150
2151        // Check for required ARIA properties
2152        for element in document.select(&ARIA_SELECTOR) {
2153            if let Some(missing_props) =
2154                get_missing_required_aria_properties(&element)
2155            {
2156                Self::add_issue(
2157                    issues,
2158                    IssueType::InvalidAria,
2159                    format!(
2160                        "Missing required ARIA properties: {}",
2161                        missing_props.join(", ")
2162                    ),
2163                    Some("WCAG 4.1.2".to_string()),
2164                    Some(element.html()),
2165                    Some("Add required ARIA properties".to_string()),
2166                );
2167            }
2168        }
2169        Ok(())
2170    }
2171}
2172
2173/// Utility functions for accessibility checks.
2174///
2175/// All items are `pub(crate)`; the module is `pub` only so its
2176/// docstrings render in rustdoc. External callers should use the
2177/// crate-level [`crate::utils`] helpers instead.
2178///
2179/// # Examples
2180///
2181/// ```
2182/// // The crate-level facade for the same checks:
2183/// assert!(html_generator::utils::is_valid_language_code("en-GB"));
2184/// ```
2185pub mod utils {
2186    use scraper::ElementRef;
2187    use std::collections::HashMap;
2188
2189    /// Validate language code against BCP 47
2190    use once_cell::sync::Lazy;
2191    use regex::Regex;
2192
2193    /// Validate language code against simplified BCP 47 rules.
2194    pub(crate) fn is_valid_language_code(lang: &str) -> bool {
2195        static LANGUAGE_CODE_REGEX: Lazy<Regex> = Lazy::new(|| {
2196            // Match primary language and optional subtags
2197            Regex::new(r"(?i)^[a-z]{2,3}(-[a-z0-9]{2,8})*$")
2198                .expect("static LANGUAGE_CODE_REGEX must compile")
2199        });
2200
2201        // Ensure the regex matches and the code does not end with a hyphen
2202        LANGUAGE_CODE_REGEX.is_match(lang) && !lang.ends_with('-')
2203    }
2204
2205    /// Check if ARIA role is valid for element
2206    pub(crate) fn is_valid_aria_role(
2207        role: &str,
2208        element: &ElementRef,
2209    ) -> bool {
2210        static VALID_ROLES: Lazy<HashMap<&str, Vec<&str>>> =
2211            Lazy::new(|| {
2212                let mut map = HashMap::new();
2213                _ = map.insert(
2214                    "button",
2215                    vec!["button", "link", "menuitem"],
2216                );
2217                _ = map.insert(
2218                    "input",
2219                    vec!["textbox", "radio", "checkbox", "button"],
2220                );
2221                _ = map.insert(
2222                    "div",
2223                    vec!["alert", "tooltip", "dialog", "slider"],
2224                );
2225                _ = map.insert("a", vec!["link", "button", "menuitem"]);
2226                map
2227            });
2228
2229        // Elements like <div>, <span>, and <a> are more permissive
2230        let tag_name = element.value().name();
2231        if ["div", "span", "a"].contains(&tag_name) {
2232            return true;
2233        }
2234
2235        // Validate roles strictly for specific elements
2236        if let Some(valid_roles) = VALID_ROLES.get(tag_name) {
2237            valid_roles.contains(&role)
2238        } else {
2239            false
2240        }
2241    }
2242
2243    /// Get missing required ARIA properties
2244    pub(crate) fn get_missing_required_aria_properties(
2245        element: &ElementRef,
2246    ) -> Option<Vec<String>> {
2247        let mut missing = Vec::new();
2248
2249        static REQUIRED_ARIA_PROPS: Lazy<HashMap<&str, Vec<&str>>> =
2250            Lazy::new(|| {
2251                HashMap::from([
2252                    (
2253                        "slider",
2254                        vec![
2255                            "aria-valuenow",
2256                            "aria-valuemin",
2257                            "aria-valuemax",
2258                        ],
2259                    ),
2260                    ("combobox", vec!["aria-expanded"]),
2261                ])
2262            });
2263
2264        if let Some(role) = element.value().attr("role") {
2265            if let Some(required_props) = REQUIRED_ARIA_PROPS.get(role)
2266            {
2267                for prop in required_props {
2268                    if element.value().attr(prop).is_none() {
2269                        missing.push((*prop).to_string());
2270                    }
2271                }
2272            }
2273        }
2274
2275        if missing.is_empty() {
2276            None
2277        } else {
2278            Some(missing)
2279        }
2280    }
2281}
2282
2283#[cfg(test)]
2284mod tests {
2285    use super::*;
2286
2287    /// Drives [`DomReplacer`] directly with crafted inputs that force
2288    /// the shorthand-attribute fallback and the last-resort branches.
2289    mod dom_replacer_fallbacks {
2290        use super::*;
2291
2292        #[test]
2293        fn direct_match_hits_the_fast_path() {
2294            let mut r = DomReplacer::new("<p>one</p><p>two</p>");
2295            r.push("<p>one</p>", "<p>ONE</p>");
2296            r.push("<p>two</p>", "<p>TWO</p>");
2297            assert_eq!(r.apply(), "<p>ONE</p><p>TWO</p>");
2298        }
2299
2300        #[test]
2301        fn shorthand_fallback_anchors_via_tag_name() {
2302            // Source has `disabled` (shorthand); queued `old_html` has
2303            // `disabled=""` (scraper-normalised form). Direct find
2304            // fails; the normalised fallback matches and the
2305            // opening-tag anchor locates the original span.
2306            let source = r#"<form><input disabled type="text"></form>"#;
2307            let mut r = DomReplacer::new(source);
2308            r.push(
2309                r#"<input disabled="" type="text">"#,
2310                r#"<input disabled aria-disabled="true" type="text">"#,
2311            );
2312            let out = r.apply();
2313            assert!(
2314                out.contains("aria-disabled"),
2315                "shorthand fallback should patch source: {out}"
2316            );
2317        }
2318
2319        #[test]
2320        fn last_resort_path_fires_when_fallback_misses() {
2321            // Queue an `old_html` that isn't present in source at all
2322            // under any normalisation — the whole pipeline must fall
2323            // through every branch without panicking and return the
2324            // source unchanged.
2325            let mut r = DomReplacer::new("<p>unchanged</p>");
2326            r.push("<nonexistent></nonexistent>", "<whatever/>");
2327            assert_eq!(r.apply(), "<p>unchanged</p>");
2328        }
2329
2330        #[test]
2331        fn duplicate_identical_snippets_map_to_distinct_occurrences() {
2332            // When the same `old_html` is pushed twice, the two
2333            // replacements must land on occurrence #1 and #2
2334            // respectively, not the same byte offset.
2335            let source = "<span>x</span><span>x</span>";
2336            let mut r = DomReplacer::new(source);
2337            r.push("<span>x</span>", "<span>A</span>");
2338            r.push("<span>x</span>", "<span>B</span>");
2339            assert_eq!(r.apply(), "<span>A</span><span>B</span>");
2340        }
2341
2342        #[test]
2343        fn extract_opening_tag_handles_self_terminating_input() {
2344            // Standard case: tag with attributes ends at the first
2345            // whitespace.
2346            assert_eq!(
2347                extract_opening_tag(r#"<button id="x">click</button>"#),
2348                "<button"
2349            );
2350            // Self-closing case: tag ends at `/`.
2351            assert_eq!(extract_opening_tag(r#"<input/>"#), "<input");
2352            // Fallback: input has no whitespace, `>`, or `/` after the
2353            // opening `<`. The function returns the whole stripped
2354            // remainder. This is the L1160 else-branch.
2355            assert_eq!(extract_opening_tag("<naked"), "<naked");
2356            assert_eq!(extract_opening_tag("naked"), "<naked");
2357        }
2358
2359        #[test]
2360        fn shorthand_normalization_fires_closure_on_queued_old_html() {
2361            // The `replace_all` closure on `normalized_old` only runs
2362            // when the queued `old_html` itself contains a bare
2363            // shorthand boolean attribute. Push such an old_html
2364            // (the source side has the normalised form) so the
2365            // queue-side regex closure body is exercised even though
2366            // the resulting find still misses and the source is
2367            // returned unchanged.
2368            let source =
2369                r#"<form><input disabled="" type="text"></form>"#;
2370            let mut r = DomReplacer::new(source);
2371            r.push(
2372                r#"<input disabled type="text">"#,
2373                r#"<input disabled aria-disabled="true" type="text">"#,
2374            );
2375            let out = r.apply();
2376            // The fallback path locates the normalised form in source
2377            // and patches it; what matters for coverage is that the
2378            // queued-side regex closure ran on the bare-shorthand
2379            // input, not the post-condition output exactly.
2380            assert!(
2381                out.contains("aria-disabled") || out == source,
2382                "shorthand closure on old_html should have run: {out}"
2383            );
2384        }
2385    }
2386
2387    // Test WCAG Level functionality
2388    mod wcag_level_tests {
2389        use super::*;
2390
2391        #[test]
2392        fn test_wcag_level_ordering() {
2393            assert!(matches!(WcagLevel::A, WcagLevel::A));
2394            assert!(matches!(WcagLevel::AA, WcagLevel::AA));
2395            assert!(matches!(WcagLevel::AAA, WcagLevel::AAA));
2396        }
2397
2398        #[test]
2399        fn test_wcag_level_debug() {
2400            assert_eq!(format!("{:?}", WcagLevel::A), "A");
2401            assert_eq!(format!("{:?}", WcagLevel::AA), "AA");
2402            assert_eq!(format!("{:?}", WcagLevel::AAA), "AAA");
2403        }
2404    }
2405
2406    // Test AccessibilityConfig functionality
2407    mod config_tests {
2408        use super::*;
2409
2410        #[test]
2411        fn test_default_config() {
2412            let config = AccessibilityConfig::default();
2413            assert_eq!(config.wcag_level, WcagLevel::AA);
2414            assert_eq!(config.max_heading_jump, 1);
2415            assert_eq!(config.min_contrast_ratio, 4.5);
2416            assert!(config.auto_fix);
2417        }
2418
2419        #[test]
2420        fn test_custom_config() {
2421            let config = AccessibilityConfig {
2422                wcag_level: WcagLevel::AAA,
2423                max_heading_jump: 2,
2424                min_contrast_ratio: 7.0,
2425                auto_fix: false,
2426            };
2427            assert_eq!(config.wcag_level, WcagLevel::AAA);
2428            assert_eq!(config.max_heading_jump, 2);
2429            assert_eq!(config.min_contrast_ratio, 7.0);
2430            assert!(!config.auto_fix);
2431        }
2432    }
2433
2434    // Test ARIA attribute management
2435    mod aria_attribute_tests {
2436        use super::*;
2437
2438        #[test]
2439        fn test_valid_aria_attributes() {
2440            assert!(is_valid_aria_attribute("aria-label", "Test"));
2441            assert!(is_valid_aria_attribute("aria-hidden", "true"));
2442            assert!(is_valid_aria_attribute("aria-hidden", "false"));
2443            assert!(!is_valid_aria_attribute("aria-hidden", "yes"));
2444            assert!(!is_valid_aria_attribute("invalid-aria", "value"));
2445        }
2446
2447        #[test]
2448        fn test_empty_aria_value() {
2449            assert!(!is_valid_aria_attribute("aria-label", ""));
2450            assert!(!is_valid_aria_attribute("aria-label", "  "));
2451        }
2452    }
2453
2454    // Test HTML modification functions
2455    mod html_modification_tests {
2456        use super::*;
2457
2458        #[test]
2459        fn test_add_aria_to_empty_button() {
2460            let html = "<button></button>";
2461            let result = add_aria_attributes(html, None);
2462            assert!(result.is_ok());
2463            let enhanced = result.unwrap();
2464            assert!(enhanced.contains(r#"aria-label="button""#));
2465        }
2466
2467        #[test]
2468        fn test_large_input() {
2469            let large_html = "a".repeat(MAX_HTML_SIZE + 1);
2470            let result = add_aria_attributes(&large_html, None);
2471            assert!(matches!(result, Err(Error::HtmlTooLarge { .. })));
2472        }
2473    }
2474
2475    // Test accessibility validation
2476    mod validation_tests {
2477        use super::*;
2478
2479        #[test]
2480        fn test_heading_structure() {
2481            let valid_html = "<h1>Main Title</h1><h2>Subtitle</h2>";
2482            let invalid_html =
2483                "<h1>Main Title</h1><h3>Skipped Heading</h3>";
2484
2485            let config = AccessibilityConfig::default();
2486
2487            // Validate correct heading structure
2488            let valid_result = validate_wcag(
2489                valid_html,
2490                &config,
2491                Some(&[IssueType::LanguageDeclaration]),
2492            )
2493            .unwrap();
2494            assert_eq!(
2495                valid_result.issue_count, 0,
2496                "Expected no issues for valid HTML, but found: {:#?}",
2497                valid_result.issues
2498            );
2499
2500            // Validate incorrect heading structure
2501            let invalid_result = validate_wcag(
2502                invalid_html,
2503                &config,
2504                Some(&[IssueType::LanguageDeclaration]),
2505            )
2506            .unwrap();
2507            assert_eq!(
2508        invalid_result.issue_count,
2509        1,
2510        "Expected one issue for skipped heading levels, but found: {:#?}",
2511        invalid_result.issues
2512    );
2513
2514            let issue = &invalid_result.issues[0];
2515            assert_eq!(issue.issue_type, IssueType::HeadingStructure);
2516            assert_eq!(
2517                issue.message,
2518                "Skipped heading level from h1 to h3"
2519            );
2520            assert_eq!(issue.guideline, Some("WCAG 2.4.6".to_string()));
2521            assert_eq!(
2522                issue.suggestion,
2523                Some("Use sequential heading levels".to_string())
2524            );
2525        }
2526    }
2527
2528    // Test report generation
2529    mod report_tests {
2530        use super::*;
2531
2532        #[test]
2533        fn test_report_generation() {
2534            let html = r#"<img src="test.jpg">"#;
2535            let config = AccessibilityConfig::default();
2536            let report = validate_wcag(html, &config, None).unwrap();
2537
2538            assert!(report.issue_count > 0);
2539
2540            assert_eq!(report.wcag_level, WcagLevel::AA);
2541        }
2542
2543        #[test]
2544        fn test_empty_html_report() {
2545            let html = "";
2546            let config = AccessibilityConfig::default();
2547            let report = validate_wcag(html, &config, None).unwrap();
2548
2549            assert_eq!(report.elements_checked, 0);
2550            assert_eq!(report.issue_count, 0);
2551        }
2552
2553        #[test]
2554        fn test_missing_selector_handling() {
2555            // Simulate a scenario where NAV_SELECTOR fails to initialize.
2556            static TEST_NAV_SELECTOR: Lazy<Option<Selector>> =
2557                Lazy::new(|| None);
2558
2559            let html = "<nav>Main Navigation</nav>";
2560            let document = Html::parse_document(html);
2561
2562            if let Some(selector) = TEST_NAV_SELECTOR.as_ref() {
2563                let navs: Vec<_> = document.select(selector).collect();
2564                assert_eq!(navs.len(), 0);
2565            }
2566        }
2567
2568        #[test]
2569        fn test_html_processing_error_with_source() {
2570            let source_error =
2571                std::io::Error::other("test source error");
2572            let error = Error::HtmlProcessingError {
2573                message: "Processing failed".to_string(),
2574                source: Some(Box::new(source_error)),
2575            };
2576
2577            assert_eq!(
2578                format!("{}", error),
2579                "HTML Processing Error: Processing failed"
2580            );
2581        }
2582    }
2583    #[cfg(test)]
2584    mod utils_tests {
2585        use super::*;
2586
2587        mod language_code_validation {
2588            use super::*;
2589
2590            #[test]
2591            fn test_valid_language_codes() {
2592                let valid_codes = [
2593                    "en", "en-US", "zh-CN", "fr-FR", "de-DE", "es-419",
2594                    "ar-001", "pt-BR", "ja-JP", "ko-KR",
2595                ];
2596                for code in valid_codes {
2597                    assert!(
2598                        is_valid_language_code(code),
2599                        "Language code '{}' should be valid",
2600                        code
2601                    );
2602                }
2603            }
2604
2605            #[test]
2606            fn test_invalid_language_codes() {
2607                let invalid_codes = [
2608                    "",               // Empty string
2609                    "a",              // Single character
2610                    "123",            // Numeric code
2611                    "en_US",          // Underscore instead of hyphen
2612                    "en-",            // Trailing hyphen
2613                    "-en",            // Leading hyphen
2614                    "en--US",         // Consecutive hyphens
2615                    "toolong",        // Primary subtag too long
2616                    "en-US-INVALID-", // Trailing hyphen with subtags
2617                ];
2618                for code in invalid_codes {
2619                    assert!(
2620                        !is_valid_language_code(code),
2621                        "Language code '{}' should be invalid",
2622                        code
2623                    );
2624                }
2625            }
2626
2627            #[test]
2628            fn test_language_code_case_sensitivity() {
2629                assert!(is_valid_language_code("en-GB"));
2630                assert!(is_valid_language_code("fr-FR"));
2631                assert!(is_valid_language_code("zh-Hans"));
2632                assert!(is_valid_language_code("EN-GB"));
2633            }
2634        }
2635
2636        mod aria_role_validation {
2637            use super::*;
2638
2639            #[test]
2640            fn test_valid_button_roles() {
2641                let html = "<button>Test</button>";
2642                let fragment = Html::parse_fragment(html);
2643                let selector = Selector::parse("button").unwrap();
2644                let element =
2645                    fragment.select(&selector).next().unwrap();
2646                let valid_roles = ["button", "link", "menuitem"];
2647                for role in valid_roles {
2648                    assert!(
2649                        is_valid_aria_role(role, &element),
2650                        "Role '{}' should be valid for button",
2651                        role
2652                    );
2653                }
2654            }
2655
2656            #[test]
2657            fn test_valid_input_roles() {
2658                let html = "<input type='text'>";
2659                let fragment = Html::parse_fragment(html);
2660                let selector = Selector::parse("input").unwrap();
2661                let element =
2662                    fragment.select(&selector).next().unwrap();
2663                let valid_roles =
2664                    ["textbox", "radio", "checkbox", "button"];
2665                for role in valid_roles {
2666                    assert!(
2667                        is_valid_aria_role(role, &element),
2668                        "Role '{}' should be valid for input",
2669                        role
2670                    );
2671                }
2672            }
2673
2674            #[test]
2675            fn test_valid_anchor_roles() {
2676                let html = "<a href=\"\\#\">Test</a>";
2677                let fragment = Html::parse_fragment(html);
2678                let selector = Selector::parse("a").unwrap();
2679                let element =
2680                    fragment.select(&selector).next().unwrap();
2681
2682                let valid_roles = ["button", "link", "menuitem"];
2683                for role in valid_roles {
2684                    assert!(
2685                        is_valid_aria_role(role, &element),
2686                        "Role '{}' should be valid for anchor",
2687                        role
2688                    );
2689                }
2690            }
2691
2692            #[test]
2693            fn test_invalid_element_roles() {
2694                let html = "<button>Test</button>";
2695                let fragment = Html::parse_fragment(html);
2696                let selector = Selector::parse("button").unwrap();
2697                let element =
2698                    fragment.select(&selector).next().unwrap();
2699                let invalid_roles =
2700                    ["textbox", "radio", "checkbox", "invalid"];
2701                for role in invalid_roles {
2702                    assert!(
2703                        !is_valid_aria_role(role, &element),
2704                        "Role '{}' should be invalid for button",
2705                        role
2706                    );
2707                }
2708            }
2709
2710            #[test]
2711            fn test_unrestricted_elements() {
2712                // Testing with <div>
2713                let html_div = "<div>Test</div>";
2714                let fragment_div = Html::parse_fragment(html_div);
2715                let selector_div = Selector::parse("div").unwrap();
2716                let element_div =
2717                    fragment_div.select(&selector_div).next().unwrap();
2718
2719                // Testing with <span>
2720                let html_span = "<span>Test</span>";
2721                let fragment_span = Html::parse_fragment(html_span);
2722                let selector_span = Selector::parse("span").unwrap();
2723                let element_span = fragment_span
2724                    .select(&selector_span)
2725                    .next()
2726                    .unwrap();
2727
2728                let roles =
2729                    ["button", "textbox", "navigation", "banner"];
2730
2731                for role in roles {
2732                    assert!(
2733                        is_valid_aria_role(role, &element_div),
2734                        "Role '{}' should be allowed for div",
2735                        role
2736                    );
2737                    assert!(
2738                        is_valid_aria_role(role, &element_span),
2739                        "Role '{}' should be allowed for span",
2740                        role
2741                    );
2742                }
2743            }
2744
2745            #[test]
2746            fn test_validate_wcag_with_level_aaa() {
2747                let html =
2748                    "<h1>Main Title</h1><h3>Skipped Heading</h3>";
2749                let config = AccessibilityConfig {
2750                    wcag_level: WcagLevel::AAA,
2751                    ..Default::default()
2752                };
2753                let report =
2754                    validate_wcag(html, &config, None).unwrap();
2755                assert!(report.issue_count > 0);
2756                assert_eq!(report.wcag_level, WcagLevel::AAA);
2757            }
2758
2759            #[test]
2760            fn test_html_builder_empty() {
2761                let builder = HtmlBuilder::new("");
2762                assert_eq!(builder.build(), "");
2763            }
2764
2765            #[test]
2766            fn test_generate_unique_id_uniqueness() {
2767                let id1 = generate_unique_id();
2768                let id2 = generate_unique_id();
2769                assert_ne!(id1, id2);
2770            }
2771        }
2772
2773        mod required_aria_properties {
2774            use super::*;
2775            use scraper::{Html, Selector};
2776
2777            #[test]
2778            fn test_combobox_required_properties() {
2779                let html = r#"<div role="combobox">Test</div>"#;
2780                let fragment = Html::parse_fragment(html);
2781                let selector = Selector::parse("div").unwrap();
2782                let element =
2783                    fragment.select(&selector).next().unwrap();
2784
2785                let missing =
2786                    get_missing_required_aria_properties(&element)
2787                        .unwrap();
2788                assert!(missing.contains(&"aria-expanded".to_string()));
2789            }
2790
2791            #[test]
2792            fn test_complete_combobox() {
2793                let html = r#"<div role="combobox" aria-expanded="true">Test</div>"#;
2794                let fragment = Html::parse_fragment(html);
2795                let selector = Selector::parse("div").unwrap();
2796                let element =
2797                    fragment.select(&selector).next().unwrap();
2798
2799                let missing =
2800                    get_missing_required_aria_properties(&element);
2801                assert!(missing.is_none());
2802            }
2803
2804            #[test]
2805            fn test_add_aria_attributes_empty_html() {
2806                let html = "";
2807                let result = add_aria_attributes(html, None);
2808                assert!(result.is_ok());
2809                assert_eq!(result.unwrap(), "");
2810            }
2811
2812            #[test]
2813            fn test_add_aria_attributes_whitespace_html() {
2814                let html = "   ";
2815                let result = add_aria_attributes(html, None);
2816                assert!(result.is_ok());
2817                assert_eq!(result.unwrap(), "   ");
2818            }
2819
2820            #[test]
2821            fn test_validate_wcag_with_minimal_config() {
2822                let html = r#"<html lang="en"><div>Accessible Content</div></html>"#;
2823                let config = AccessibilityConfig {
2824                    wcag_level: WcagLevel::A,
2825                    max_heading_jump: 0, // No heading enforcement
2826                    min_contrast_ratio: 0.0, // No contrast enforcement
2827                    auto_fix: false,
2828                };
2829                let report =
2830                    validate_wcag(html, &config, None).unwrap();
2831                assert_eq!(report.issue_count, 0);
2832            }
2833
2834            #[test]
2835            fn test_add_partial_aria_attributes_to_button() {
2836                let html =
2837                    r#"<button aria-label="Existing">Click</button>"#;
2838                let result = add_aria_attributes(html, None);
2839                assert!(result.is_ok());
2840                let enhanced = result.unwrap();
2841                assert!(enhanced.contains(r#"aria-label="Existing""#));
2842            }
2843
2844            #[test]
2845            fn test_add_aria_to_elements_with_existing_roles() {
2846                let html = r#"<nav aria-label=\"navigation\" role=\"navigation\" role=\"navigation\">Content</nav>"#;
2847                let result = add_aria_attributes(html, None);
2848                assert!(result.is_ok());
2849                assert_eq!(result.unwrap(), html);
2850            }
2851
2852            #[test]
2853            fn test_slider_required_properties() {
2854                let html = r#"<div role="slider">Test</div>"#;
2855                let fragment = Html::parse_fragment(html);
2856                let selector = Selector::parse("div").unwrap();
2857                let element =
2858                    fragment.select(&selector).next().unwrap();
2859
2860                let missing =
2861                    get_missing_required_aria_properties(&element)
2862                        .unwrap();
2863
2864                assert!(missing.contains(&"aria-valuenow".to_string()));
2865                assert!(missing.contains(&"aria-valuemin".to_string()));
2866                assert!(missing.contains(&"aria-valuemax".to_string()));
2867            }
2868
2869            #[test]
2870            fn test_complete_slider() {
2871                let html = r#"<div role="slider"
2872                   aria-valuenow="50"
2873                   aria-valuemin="0"
2874                   aria-valuemax="100">Test</div>"#;
2875                let fragment = Html::parse_fragment(html);
2876                let selector = Selector::parse("div").unwrap();
2877                let element =
2878                    fragment.select(&selector).next().unwrap();
2879
2880                let missing =
2881                    get_missing_required_aria_properties(&element);
2882                assert!(missing.is_none());
2883            }
2884
2885            #[test]
2886            fn test_partial_slider_properties() {
2887                let html = r#"<div role="slider" aria-valuenow="50">Test</div>"#;
2888                let fragment = Html::parse_fragment(html);
2889                let selector = Selector::parse("div").unwrap();
2890                let element =
2891                    fragment.select(&selector).next().unwrap();
2892
2893                let missing =
2894                    get_missing_required_aria_properties(&element)
2895                        .unwrap();
2896
2897                assert!(!missing.contains(&"aria-valuenow".to_string()));
2898                assert!(missing.contains(&"aria-valuemin".to_string()));
2899                assert!(missing.contains(&"aria-valuemax".to_string()));
2900            }
2901
2902            #[test]
2903            fn test_unknown_role() {
2904                let html = r#"<div role="unknown">Test</div>"#;
2905                let fragment = Html::parse_fragment(html);
2906                let selector = Selector::parse("div").unwrap();
2907                let element =
2908                    fragment.select(&selector).next().unwrap();
2909
2910                let missing =
2911                    get_missing_required_aria_properties(&element);
2912                assert!(missing.is_none());
2913            }
2914
2915            #[test]
2916            fn test_no_role() {
2917                let html = "<div>Test</div>";
2918                let fragment = Html::parse_fragment(html);
2919                let selector = Selector::parse("div").unwrap();
2920                let element =
2921                    fragment.select(&selector).next().unwrap();
2922
2923                let missing =
2924                    get_missing_required_aria_properties(&element);
2925                assert!(missing.is_none());
2926            }
2927        }
2928    }
2929
2930    #[cfg(test)]
2931    mod accessibility_tests {
2932        use crate::accessibility::{
2933            get_missing_required_aria_properties, is_valid_aria_role,
2934            is_valid_language_code,
2935        };
2936        use scraper::Selector;
2937
2938        #[test]
2939        fn test_is_valid_language_code() {
2940            assert!(
2941                is_valid_language_code("en"),
2942                "Valid language code 'en' was incorrectly rejected"
2943            );
2944            assert!(
2945                is_valid_language_code("en-US"),
2946                "Valid language code 'en-US' was incorrectly rejected"
2947            );
2948            assert!(
2949                !is_valid_language_code("123"),
2950                "Invalid language code '123' was incorrectly accepted"
2951            );
2952            assert!(!is_valid_language_code("日本語"), "Non-ASCII language code '日本語' was incorrectly accepted");
2953        }
2954
2955        #[test]
2956        fn test_is_valid_aria_role() {
2957            use scraper::Html;
2958
2959            let html = r#"<button></button>"#;
2960            let document = Html::parse_fragment(html);
2961            let element = document
2962                .select(&Selector::parse("button").unwrap())
2963                .next()
2964                .unwrap();
2965
2966            assert!(
2967                is_valid_aria_role("button", &element),
2968                "Valid ARIA role 'button' was incorrectly rejected"
2969            );
2970
2971            assert!(
2972        !is_valid_aria_role("invalid-role", &element),
2973        "Invalid ARIA role 'invalid-role' was incorrectly accepted"
2974    );
2975        }
2976
2977        #[test]
2978        fn test_get_missing_required_aria_properties() {
2979            use scraper::{Html, Selector};
2980
2981            // Case 1: Missing all properties for slider
2982            let html = r#"<div role="slider"></div>"#;
2983            let document = Html::parse_fragment(html);
2984            let element = document
2985                .select(&Selector::parse("div").unwrap())
2986                .next()
2987                .unwrap();
2988
2989            let missing_props =
2990                get_missing_required_aria_properties(&element).unwrap();
2991            assert!(
2992        missing_props.contains(&"aria-valuenow".to_string()),
2993        "Did not detect missing 'aria-valuenow' for role 'slider'"
2994    );
2995            assert!(
2996        missing_props.contains(&"aria-valuemin".to_string()),
2997        "Did not detect missing 'aria-valuemin' for role 'slider'"
2998    );
2999            assert!(
3000        missing_props.contains(&"aria-valuemax".to_string()),
3001        "Did not detect missing 'aria-valuemax' for role 'slider'"
3002    );
3003
3004            // Case 2: All properties present
3005            let html = r#"<div role="slider" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>"#;
3006            let document = Html::parse_fragment(html);
3007            let element = document
3008                .select(&Selector::parse("div").unwrap())
3009                .next()
3010                .unwrap();
3011
3012            let missing_props =
3013                get_missing_required_aria_properties(&element);
3014            assert!(missing_props.is_none(), "Unexpectedly found missing properties for a complete slider");
3015
3016            // Case 3: Partially missing properties
3017            let html =
3018                r#"<div role="slider" aria-valuenow="50"></div>"#;
3019            let document = Html::parse_fragment(html);
3020            let element = document
3021                .select(&Selector::parse("div").unwrap())
3022                .next()
3023                .unwrap();
3024
3025            let missing_props =
3026                get_missing_required_aria_properties(&element).unwrap();
3027            assert!(
3028                !missing_props.contains(&"aria-valuenow".to_string()),
3029                "Incorrectly flagged 'aria-valuenow' as missing"
3030            );
3031            assert!(
3032        missing_props.contains(&"aria-valuemin".to_string()),
3033        "Did not detect missing 'aria-valuemin' for role 'slider'"
3034    );
3035            assert!(
3036        missing_props.contains(&"aria-valuemax".to_string()),
3037        "Did not detect missing 'aria-valuemax' for role 'slider'"
3038    );
3039        }
3040    }
3041
3042    #[cfg(test)]
3043    mod additional_tests {
3044        use super::*;
3045        use scraper::Html;
3046
3047        #[test]
3048        fn test_validate_empty_html() {
3049            let html = "";
3050            let config = AccessibilityConfig::default();
3051            let report = validate_wcag(html, &config, None).unwrap();
3052            assert_eq!(
3053                report.issue_count, 0,
3054                "Empty HTML should not produce issues"
3055            );
3056        }
3057
3058        #[test]
3059        fn test_validate_only_whitespace_html() {
3060            let html = "   ";
3061            let config = AccessibilityConfig::default();
3062            let report = validate_wcag(html, &config, None).unwrap();
3063            assert_eq!(
3064                report.issue_count, 0,
3065                "Whitespace-only HTML should not produce issues"
3066            );
3067        }
3068
3069        #[test]
3070        fn test_validate_language_with_edge_cases() {
3071            let html = "<html lang=\"en-US\"></html>";
3072            let _config = AccessibilityConfig::default();
3073            let mut issues = Vec::new();
3074            let document = Html::parse_document(html);
3075
3076            AccessibilityReport::check_language_attributes(
3077                &document,
3078                &mut issues,
3079            )
3080            .unwrap();
3081            assert_eq!(
3082                issues.len(),
3083                0,
3084                "Valid language declaration should not create issues"
3085            );
3086        }
3087
3088        #[test]
3089        fn test_validate_invalid_language_code() {
3090            let html = "<html lang=\"invalid-lang\"></html>";
3091            let _config = AccessibilityConfig::default();
3092            let mut issues = Vec::new();
3093            let document = Html::parse_document(html);
3094
3095            AccessibilityReport::check_language_attributes(
3096                &document,
3097                &mut issues,
3098            )
3099            .unwrap();
3100            assert!(
3101                issues
3102                    .iter()
3103                    .any(|i| i.issue_type
3104                        == IssueType::LanguageDeclaration),
3105                "Failed to detect invalid language declaration"
3106            );
3107        }
3108
3109        #[test]
3110        fn test_edge_case_for_generate_unique_id() {
3111            let ids: Vec<String> =
3112                (0..100).map(|_| generate_unique_id()).collect();
3113            let unique_ids: HashSet<String> = ids.into_iter().collect();
3114            assert_eq!(
3115                unique_ids.len(),
3116                100,
3117                "Generated IDs are not unique in edge case testing"
3118            );
3119        }
3120
3121        #[test]
3122        fn test_html_with_non_standard_elements() {
3123            let html =
3124                "<custom-element aria-label=\"test\"></custom-element>";
3125            let cleaned_html = remove_invalid_aria_attributes(html);
3126            assert_eq!(cleaned_html, html, "Unexpectedly modified valid custom element with ARIA attributes");
3127        }
3128
3129        #[test]
3130        fn test_add_aria_to_buttons() {
3131            let html = r#"<button>Click me</button>"#;
3132            let builder = HtmlBuilder::new(html);
3133            let result = add_aria_to_buttons(builder).unwrap().build();
3134            assert!(result.contains("aria-label"));
3135        }
3136
3137        #[test]
3138        fn test_add_aria_to_empty_buttons() {
3139            let html = r#"<button></button>"#;
3140            let builder = HtmlBuilder::new(html);
3141            let result = add_aria_to_buttons(builder).unwrap();
3142            assert!(result.build().contains("aria-label"));
3143        }
3144
3145        #[test]
3146        fn test_validate_wcag_empty_html() {
3147            let html = "";
3148            let config = AccessibilityConfig::default();
3149            let disable_checks = None;
3150
3151            let result = validate_wcag(html, &config, disable_checks);
3152
3153            match result {
3154                Ok(report) => assert!(
3155                    report.issues.is_empty(),
3156                    "Empty HTML should have no issues"
3157                ),
3158                Err(e) => {
3159                    panic!("Validation failed with error: {:?}", e)
3160                }
3161            }
3162        }
3163
3164        #[test]
3165        fn test_validate_wcag_with_complex_html() {
3166            let html = "
3167            <html>
3168                <head></head>
3169                <body>
3170                    <button>Click me</button>
3171                    <a href=\"\\#\"></a>
3172                </body>
3173            </html>
3174        ";
3175            let config = AccessibilityConfig::default();
3176            let disable_checks = None;
3177            let result = validate_wcag(html, &config, disable_checks);
3178
3179            match result {
3180                Ok(report) => assert!(
3181                    !report.issues.is_empty(),
3182                    "Report should have issues"
3183                ),
3184                Err(e) => {
3185                    panic!("Validation failed with error: {:?}", e)
3186                }
3187            }
3188        }
3189
3190        #[test]
3191        fn test_generate_unique_id_uniqueness() {
3192            let id1 = generate_unique_id();
3193            let id2 = generate_unique_id();
3194            assert_ne!(id1, id2);
3195        }
3196
3197        #[test]
3198        fn test_try_create_selector_valid() {
3199            // A well-formed selector parses and the Selector is usable
3200            // for matching against a parsed document.
3201            let selector = try_create_selector("div.class");
3202            let html = scraper::Html::parse_fragment(
3203                r#"<div class="class">x</div>"#,
3204            );
3205            assert!(html.select(&selector).next().is_some());
3206        }
3207
3208        #[test]
3209        #[should_panic(expected = "static selector")]
3210        fn test_try_create_selector_invalid_panics() {
3211            // Malformed selectors must panic loudly so a bad static
3212            // pattern does not silently disable a feature at runtime.
3213            let _ = try_create_selector("div..class");
3214        }
3215
3216        #[test]
3217        fn test_try_create_regex_valid() {
3218            // A well-formed pattern compiles and the Regex is usable.
3219            let regex = try_create_regex(r"\d+");
3220            assert!(regex.is_match("42"));
3221        }
3222
3223        #[test]
3224        #[should_panic(expected = "static regex")]
3225        fn test_try_create_regex_invalid_panics() {
3226            // Malformed patterns must panic loudly so a bad static
3227            // pattern does not silently disable a feature at runtime.
3228            let _ = try_create_regex(r"\d+(");
3229        }
3230
3231        /// Test `From<TryFromIntError>` for `Error`
3232        #[test]
3233        fn test_error_from_try_from_int_error() {
3234            // Trigger a TryFromIntError by attempting to convert a large integer
3235            let result: std::result::Result<u8, _> = i32::try_into(300); // This will fail
3236            let err = result.unwrap_err(); // Extract the TryFromIntError
3237            let error: Error = Error::from(err);
3238
3239            if let Error::HtmlProcessingError { message, source } =
3240                error
3241            {
3242                assert_eq!(message, "Integer conversion error");
3243                assert!(source.is_some());
3244            } else {
3245                panic!("Expected HtmlProcessingError");
3246            }
3247        }
3248
3249        /// Test `Display` implementation for `WcagLevel`
3250        #[test]
3251        fn test_wcag_level_display() {
3252            assert_eq!(WcagLevel::A.to_string(), "A");
3253            assert_eq!(WcagLevel::AA.to_string(), "AA");
3254            assert_eq!(WcagLevel::AAA.to_string(), "AAA");
3255        }
3256
3257        /// Test `check_keyboard_navigation`
3258        #[test]
3259        fn test_check_keyboard_navigation() {
3260            let document =
3261                Html::parse_document("<a tabindex='-1'></a>");
3262            let mut issues = vec![];
3263            let result = AccessibilityReport::check_keyboard_navigation(
3264                &document,
3265                &mut issues,
3266            );
3267            assert!(result.is_ok());
3268            assert_eq!(issues.len(), 1);
3269            assert_eq!(
3270                issues[0].message,
3271                "Negative tabindex prevents keyboard focus"
3272            );
3273        }
3274
3275        /// Test `check_language_attributes`
3276        #[test]
3277        fn test_check_language_attributes() {
3278            let document = Html::parse_document("<html></html>");
3279            let mut issues = vec![];
3280            let result = AccessibilityReport::check_language_attributes(
3281                &document,
3282                &mut issues,
3283            );
3284            assert!(result.is_ok());
3285            assert_eq!(issues.len(), 1);
3286            assert_eq!(
3287                issues[0].message,
3288                "Missing language declaration"
3289            );
3290        }
3291    }
3292
3293    mod missing_tests {
3294        use super::*;
3295        use std::collections::HashSet;
3296
3297        /// Test for color contrast ratio calculation
3298        #[test]
3299        fn test_color_contrast_ratio() {
3300            let low_contrast = 2.5;
3301            let high_contrast = 7.1;
3302
3303            let config = AccessibilityConfig {
3304                min_contrast_ratio: 4.5,
3305                ..Default::default()
3306            };
3307
3308            assert!(
3309                low_contrast < config.min_contrast_ratio,
3310                "Low contrast should not pass"
3311            );
3312
3313            assert!(
3314                high_contrast >= config.min_contrast_ratio,
3315                "High contrast should pass"
3316            );
3317        }
3318
3319        /// Test dynamic content ARIA attributes
3320        #[test]
3321        fn test_dynamic_content_aria_attributes() {
3322            let html = r#"<div aria-live="polite"></div>"#;
3323            let cleaned_html = remove_invalid_aria_attributes(html);
3324            assert_eq!(
3325                cleaned_html, html,
3326                "Dynamic content ARIA attributes should be preserved"
3327            );
3328        }
3329
3330        /// Test strict WCAG AAA behavior
3331        #[test]
3332        fn test_strict_wcag_aaa_behavior() {
3333            let html = r#"<h1>Main Title</h1><h4>Skipped Level</h4>"#;
3334            let config = AccessibilityConfig {
3335                wcag_level: WcagLevel::AAA,
3336                ..Default::default()
3337            };
3338
3339            let report = validate_wcag(html, &config, None).unwrap();
3340            assert!(
3341                report.issue_count > 0,
3342                "WCAG AAA strictness should detect issues"
3343            );
3344
3345            let issue = &report.issues[0];
3346            assert_eq!(
3347                issue.issue_type,
3348                IssueType::LanguageDeclaration,
3349                "Expected heading structure issue"
3350            );
3351        }
3352
3353        /// Test performance with large HTML input
3354        #[test]
3355        fn test_large_html_performance() {
3356            let large_html =
3357                "<div>".repeat(1_000) + &"</div>".repeat(1_000);
3358            let result = validate_wcag(
3359                &large_html,
3360                &AccessibilityConfig::default(),
3361                None,
3362            );
3363            assert!(
3364                result.is_ok(),
3365                "Large HTML should not cause performance issues"
3366            );
3367        }
3368
3369        /// Test nested elements with ARIA attributes
3370        #[test]
3371        fn test_nested_elements_with_aria_attributes() {
3372            let html = r#"
3373        <div>
3374            <button aria-label="Test">Click</button>
3375            <nav aria-label="Main Navigation">
3376                <ul><li>Item 1</li></ul>
3377            </nav>
3378        </div>
3379        "#;
3380            let enhanced_html =
3381                add_aria_attributes(html, None).unwrap();
3382            assert!(
3383                enhanced_html.contains("aria-label"),
3384                "Nested elements should have ARIA attributes"
3385            );
3386        }
3387
3388        /// Test heading structure validation with deeply nested headings
3389        #[test]
3390        fn test_deeply_nested_headings() {
3391            let html = r#"
3392        <div>
3393            <h1>Main Title</h1>
3394            <div>
3395                <h3>Skipped Level</h3>
3396            </div>
3397        </div>
3398        "#;
3399            let mut issues = Vec::new();
3400            let document = Html::parse_document(html);
3401            check_heading_structure(&document, &mut issues);
3402
3403            assert!(
3404            issues.iter().any(|issue| issue.issue_type == IssueType::HeadingStructure),
3405            "Deeply nested headings with skipped levels should produce issues"
3406        );
3407        }
3408
3409        /// Test unique ID generation over a long runtime
3410        #[test]
3411        fn test_unique_id_long_runtime() {
3412            let ids: HashSet<_> =
3413                (0..10_000).map(|_| generate_unique_id()).collect();
3414            assert_eq!(
3415                ids.len(),
3416                10_000,
3417                "Generated IDs should be unique over long runtime"
3418            );
3419        }
3420
3421        /// Malformed selector literal: must panic (not silently disable).
3422        #[test]
3423        #[should_panic(expected = "static selector")]
3424        fn test_custom_selector_failure_panics() {
3425            let _ = try_create_selector("div..class");
3426        }
3427
3428        /// Malformed regex literal: must panic (not silently disable).
3429        #[test]
3430        #[should_panic(expected = "static regex")]
3431        fn test_invalid_regex_pattern_panics() {
3432            let _ = try_create_regex(r"\d+(");
3433        }
3434
3435        /// Test ARIA attribute removal with invalid values
3436        #[test]
3437        fn test_invalid_aria_attribute_removal() {
3438            let html = r#"<div aria-hidden="invalid"></div>"#;
3439            let cleaned_html = remove_invalid_aria_attributes(html);
3440            assert!(
3441                !cleaned_html.contains("aria-hidden"),
3442                "Invalid ARIA attributes should be removed"
3443            );
3444        }
3445
3446        // Test invalid selector handling — panics under the loud-failure
3447        // contract (see try_create_selector docs).
3448        #[test]
3449        #[should_panic(expected = "static selector")]
3450        fn test_invalid_selector_panics() {
3451            let _ = try_create_selector("div..class");
3452        }
3453
3454        // Test `issue_type` handling in `Issue` struct
3455        #[test]
3456        fn test_issue_type_in_issue_struct() {
3457            let issue = Issue {
3458                issue_type: IssueType::MissingAltText,
3459                message: "Alt text is missing".to_string(),
3460                guideline: Some("WCAG 1.1.1".to_string()),
3461                element: Some("<img>".to_string()),
3462                suggestion: Some(
3463                    "Add descriptive alt text".to_string(),
3464                ),
3465            };
3466            assert_eq!(issue.issue_type, IssueType::MissingAltText);
3467        }
3468
3469        // Test `add_aria_to_navs`
3470        #[test]
3471        fn test_add_aria_to_navs() {
3472            let html = "<nav>Main Navigation</nav>";
3473            let builder = HtmlBuilder::new(html);
3474            let result = add_aria_to_navs(builder).unwrap().build();
3475            assert!(result.contains(r#"aria-label="navigation""#));
3476            assert!(result.contains(r#"role="navigation""#));
3477        }
3478
3479        // Test `add_aria_to_forms`
3480        #[test]
3481        fn test_add_aria_to_forms() {
3482            let html = r#"<form>Form Content</form>"#;
3483            let result =
3484                add_aria_to_forms(HtmlBuilder::new(html)).unwrap();
3485            let content = result.build();
3486
3487            assert!(content.contains(r#"id="form-"#));
3488            assert!(content.contains(r#"aria-labelledby="form-"#));
3489        }
3490
3491        // Test `check_keyboard_navigation` click handlers without keyboard equivalents
3492        #[test]
3493        fn test_check_keyboard_navigation_click_handlers() {
3494            let html = r#"<button onclick="handleClick()"></button>"#;
3495            let document = Html::parse_document(html);
3496            let mut issues = vec![];
3497
3498            AccessibilityReport::check_keyboard_navigation(
3499                &document,
3500                &mut issues,
3501            )
3502            .unwrap();
3503
3504            assert!(
3505        issues.iter().any(|i| i.message == "Click handler without keyboard equivalent"),
3506        "Expected an issue for missing keyboard equivalents, but found: {:?}",
3507        issues
3508    );
3509        }
3510
3511        // Test invalid language codes in `check_language_attributes`
3512        #[test]
3513        fn test_invalid_language_code() {
3514            let html = r#"<html lang="invalid-lang"></html>"#;
3515            let document = Html::parse_document(html);
3516            let mut issues = vec![];
3517            AccessibilityReport::check_language_attributes(
3518                &document,
3519                &mut issues,
3520            )
3521            .unwrap();
3522            assert!(issues
3523                .iter()
3524                .any(|i| i.message.contains("Invalid language code")));
3525        }
3526
3527        // Test `get_missing_required_aria_properties`
3528        #[test]
3529        fn test_missing_required_aria_properties() {
3530            let html = r#"<div role="slider"></div>"#;
3531            let fragment = Html::parse_fragment(html);
3532            let element = fragment
3533                .select(&Selector::parse("div").unwrap())
3534                .next()
3535                .unwrap();
3536            let missing =
3537                get_missing_required_aria_properties(&element).unwrap();
3538            assert!(missing.contains(&"aria-valuenow".to_string()));
3539        }
3540
3541        /// Test invalid regex pattern handling (loud-failure contract).
3542        #[test]
3543        #[should_panic(expected = "static regex")]
3544        fn test_invalid_regex_creation_panics() {
3545            let _ = try_create_regex("[unclosed");
3546        }
3547
3548        /// Test invalid selector handling (loud-failure contract).
3549        #[test]
3550        #[should_panic(expected = "static selector")]
3551        fn test_invalid_selector_creation_panics() {
3552            let _ = try_create_selector("div..class");
3553        }
3554
3555        /// Test adding ARIA attributes to empty buttons
3556        #[test]
3557        fn test_add_aria_empty_buttons() {
3558            let html = r#"<button></button>"#;
3559            let builder = HtmlBuilder::new(html);
3560            let result = add_aria_to_buttons(builder).unwrap().build();
3561            assert!(
3562                result.contains("aria-label"),
3563                "ARIA label should be added to empty button"
3564            );
3565        }
3566
3567        /// Test WCAG validation with Level AAA strictness
3568        #[test]
3569        fn test_wcag_aaa_validation() {
3570            let html = "<h1>Main Title</h1><h4>Skipped Heading</h4>";
3571            let config = AccessibilityConfig {
3572                wcag_level: WcagLevel::AAA,
3573                ..Default::default()
3574            };
3575            let report = validate_wcag(html, &config, None).unwrap();
3576            assert!(
3577                report.issue_count > 0,
3578                "WCAG AAA should detect issues"
3579            );
3580        }
3581
3582        /// Test unique ID generation for collisions
3583        #[test]
3584        fn test_unique_id_collisions() {
3585            let ids: HashSet<_> =
3586                (0..10_000).map(|_| generate_unique_id()).collect();
3587            assert_eq!(
3588                ids.len(),
3589                10_000,
3590                "Generated IDs should be unique"
3591            );
3592        }
3593
3594        /// Test adding ARIA attributes to navigation elements
3595        #[test]
3596        fn test_add_aria_navigation() {
3597            let html = "<nav>Main Navigation</nav>";
3598            let builder = HtmlBuilder::new(html);
3599            let result = add_aria_to_navs(builder).unwrap().build();
3600            assert!(
3601                result.contains("aria-label"),
3602                "ARIA label should be added to navigation"
3603            );
3604        }
3605
3606        /// Test handling of empty HTML content
3607        #[test]
3608        fn test_empty_html_handling() {
3609            let html = "";
3610            let result = add_aria_attributes(html, None);
3611            assert!(
3612                result.is_ok(),
3613                "Empty HTML should not cause errors"
3614            );
3615            assert_eq!(
3616                result.unwrap(),
3617                "",
3618                "Empty HTML should remain unchanged"
3619            );
3620        }
3621
3622        #[test]
3623        fn test_add_aria_to_inputs_with_different_types() {
3624            let html = r#"
3625            <input type="text" placeholder="Username">
3626            <input type="password" placeholder="Password">
3627            <input type="checkbox" id="remember">
3628            <input type="radio" name="choice">
3629            <input type="submit" value="Submit">
3630            <input type="unknown">
3631        "#;
3632
3633            let builder = HtmlBuilder::new(html);
3634            let result = add_aria_to_inputs(builder).unwrap().build();
3635
3636            // Text and password inputs should be skipped (they have placeholders)
3637            assert!(!result.contains(r#"type="text".*aria-label"#));
3638            assert!(!result.contains(r#"type="password".*aria-label"#));
3639
3640            // Checkbox should have label
3641            assert!(result.contains(
3642                r#"<label for="remember">Checkbox for remember</label>"#
3643            ));
3644
3645            // Radio should have auto-generated ID and label
3646            assert!(result
3647                .contains(r#"<label for="option1">Option 1</label>"#));
3648
3649            // Submit should be skipped
3650            assert!(!result.contains(r#"type="submit".*aria-label"#));
3651
3652            // Unknown type should get aria-label
3653            assert!(result.contains(r#"aria-label="unknown""#));
3654        }
3655
3656        #[test]
3657        fn test_has_associated_label() {
3658            // Test with input that has matching label
3659            let input = r#"<input type="text" id="username">"#;
3660            let html = r#"<label for="username">Username:</label>"#;
3661            assert!(has_associated_label(input, html));
3662
3663            // Test with input that has no matching label
3664            let input = r#"<input type="text" id="username">"#;
3665            let html = r#"<label for="password">Password:</label>"#;
3666            assert!(!has_associated_label(input, html));
3667
3668            // Test with input that has no ID
3669            let input = r#"<input type="text">"#;
3670            let html = r#"<label for="username">Username:</label>"#;
3671            assert!(!has_associated_label(input, html));
3672        }
3673
3674        #[test]
3675        fn test_preserve_attributes() {
3676            // Test with typical HTML attributes (type, class)
3677            let input = r#"<input type="text" class="form-control">"#;
3678            let result = preserve_attributes(input);
3679            assert!(result.contains("type=\"text\""));
3680            assert!(result.contains("class=\"form-control\""));
3681
3682            // Test single attributes
3683            let input = r#"<input type="text">"#;
3684            let result = preserve_attributes(input);
3685            assert!(result.contains("type=\"text\""));
3686
3687            // Test with single quotes
3688            let input = r#"<input type='text'>"#;
3689            let result = preserve_attributes(input);
3690            assert!(result.contains("type='text'"));
3691
3692            // Test boolean attributes
3693            let input = r#"<input required>"#;
3694            let result = preserve_attributes(input);
3695            assert!(result.contains("required"));
3696
3697            // Test with bare input tag
3698            let input = "<input>";
3699            let result = preserve_attributes(input);
3700            assert!(
3701                result.contains("input"),
3702                "Should preserve the input tag name"
3703            );
3704
3705            // Test complex attribute values
3706            let input = r#"<input name="test" value="multiple words">"#;
3707            let result = preserve_attributes(input);
3708            assert!(result.contains("name=\"test\""));
3709            assert!(result.contains("value=\"multiple words\""));
3710        }
3711
3712        #[test]
3713        fn test_preserve_attributes_with_data_attributes() {
3714            // Print actual regex matches for debugging
3715            let input = r#"<input data-test="value" type="text">"#;
3716            let matches: Vec<String> = ATTRIBUTE_REGEX
3717                .captures_iter(input)
3718                .map(|cap| cap[0].to_string())
3719                .collect();
3720            println!("Actual matches: {:?}", matches);
3721
3722            let result = preserve_attributes(input);
3723            println!("Preserved attributes: {}", result);
3724        }
3725
3726        #[test]
3727        fn test_extract_input_type() {
3728            // Test with double quotes
3729            let input = r#"<input type="text" class="form-control">"#;
3730            assert_eq!(
3731                extract_input_type(input),
3732                Some("text".to_string())
3733            );
3734
3735            // Test with single quotes
3736            let input = r#"<input type='radio' name='choice'>"#;
3737            assert_eq!(
3738                extract_input_type(input),
3739                Some("radio".to_string())
3740            );
3741
3742            // Test with no type attribute
3743            let input = r#"<input class="form-control">"#;
3744            assert_eq!(extract_input_type(input), None);
3745
3746            // Test with empty type attribute
3747            let input = r#"<input type="" class="form-control">"#;
3748            assert_eq!(extract_input_type(input), None); // Changed this because empty type is equivalent to no type
3749        }
3750
3751        #[test]
3752        fn test_add_aria_to_inputs_with_existing_labels() {
3753            let html = r#"
3754            <input type="checkbox" id="existing">
3755            <label for="existing">Existing Label</label>
3756            <input type="radio" id="existing2">
3757            <label for="existing2">Existing Radio</label>
3758        "#;
3759
3760            let builder = HtmlBuilder::new(html);
3761            let result = add_aria_to_inputs(builder).unwrap().build();
3762
3763            // Should not modify inputs that already have labels
3764            assert!(!result.contains("aria-label"));
3765            assert_eq!(
3766            result.matches("<label").count(),
3767            2,
3768            "Should not add additional labels to elements that already have them"
3769        );
3770        }
3771
3772        #[test]
3773        fn test_add_aria_to_inputs_with_special_characters() {
3774            let html = r#"<input type="text" data-test="test's value" class="form & input">"#;
3775            let builder = HtmlBuilder::new(html);
3776            let result = add_aria_to_inputs(builder).unwrap().build();
3777
3778            // Verify attributes with special characters are preserved
3779            assert!(result.contains("data-test=\"test's value\""));
3780            assert!(result.contains("class=\"form & input\""));
3781        }
3782
3783        #[test]
3784        fn test_toggle_button() {
3785            let original_html =
3786                r#"<button type="button">Menu</button>"#;
3787            let builder = HtmlBuilder::new(original_html);
3788            let enhanced_html =
3789                add_aria_to_buttons(builder).unwrap().build();
3790
3791            // Match the current implementation's behavior
3792            assert_eq!(
3793                enhanced_html,
3794                r#"<button aria-label="menu" type="button">Menu</button>"#,
3795                "The button should be enhanced with an aria-label"
3796            );
3797        }
3798
3799        #[test]
3800        fn test_replace_element_fallback() {
3801            let original = r#"<button disabled>Click</button>"#;
3802            let old_element = r#"<button disabled="">Click</button>"#;
3803            let new_element = r#"<button aria-disabled="true" disabled="">Click</button>"#;
3804
3805            let replaced =
3806                replace_element(original, old_element, new_element);
3807
3808            // We expect the fallback to handle <button disabled> vs <button disabled="">
3809            assert!(replaced.contains(r#"aria-disabled="true""#), "Should replace with fallback even though original has disabled not disabled=\"\"");
3810        }
3811
3812        #[test]
3813        fn test_replace_element_no_match() {
3814            let original = r#"<div>Nothing to replace</div>"#;
3815            let old_element = r#"<button disabled="">Click</button>"#;
3816            let new_element = r#"<button aria-disabled="true" disabled="">Click</button>"#;
3817
3818            // We expect no changes, because there's no match
3819            let replaced =
3820                replace_element(original, old_element, new_element);
3821            assert_eq!(
3822                replaced, original,
3823                "No match means original stays unchanged"
3824            );
3825        }
3826
3827        #[test]
3828        fn test_normalize_shorthand_attributes_multiple() {
3829            let html = r#"<input disabled selected><button disabled>Press</button>"#;
3830            let normalized = normalize_shorthand_attributes(html);
3831            // <input disabled=""> should become <input disabled="" selected="">
3832            // <button disabled=""> should become <button disabled="">
3833            assert!(
3834                normalized
3835                    .contains(r#"<input disabled="" selected="">"#),
3836                "Should expand both disabled and selected"
3837            );
3838            assert!(
3839                normalized.contains(r#"<button disabled="">"#),
3840                "Should expand the disabled attribute on the button"
3841            );
3842        }
3843
3844        #[test]
3845        fn test_remove_invalid_aria_attributes() {
3846            let html = r#"<div aria-hidden="invalid" aria-pressed="true"></div>"#;
3847            // aria-hidden="invalid" is not valid (only "true" or "false")
3848            // aria-pressed="true" is valid
3849            let cleaned = remove_invalid_aria_attributes(html);
3850            assert!(
3851                !cleaned.contains(r#"aria-hidden="invalid""#),
3852                "Invalid aria-hidden should be removed"
3853            );
3854            assert!(
3855                cleaned.contains(r#"aria-pressed="true""#),
3856                "Valid attribute should remain"
3857            );
3858        }
3859
3860        #[test]
3861        fn test_is_valid_aria_attribute_cases() {
3862            // 5a) Valid known attribute
3863            assert!(
3864                is_valid_aria_attribute("aria-label", "Submit"),
3865                "aria-label with non-empty string is valid"
3866            );
3867
3868            // 5b) Known boolean attribute with correct values
3869            assert!(
3870                is_valid_aria_attribute("aria-pressed", "true"),
3871                "aria-pressed=\"true\" is valid"
3872            );
3873            assert!(
3874                is_valid_aria_attribute("aria-pressed", "false"),
3875                "aria-pressed=\"false\" is valid"
3876            );
3877            assert!(
3878                !is_valid_aria_attribute("aria-pressed", "yes"),
3879                "aria-pressed only allows true/false"
3880            );
3881
3882            // 5c) Unknown ARIA attribute
3883            assert!(
3884                !is_valid_aria_attribute(
3885                    "aria-somethingrandom",
3886                    "value"
3887                ),
3888                "Unknown ARIA attribute is invalid"
3889            );
3890        }
3891
3892        #[test]
3893        fn test_add_aria_to_accordions_basic() {
3894            let html = r#"
3895        <div class="accordion">
3896            <button>Section 1</button>
3897            <div>Content 1</div>
3898            <button>Section 2</button>
3899            <div>Content 2</div>
3900        </div>
3901        "#;
3902            let builder = HtmlBuilder::new(html);
3903            let result =
3904                add_aria_to_accordions(builder).unwrap().build();
3905
3906            // Expect to see aria-expanded="false", aria-controls="section-1-content" etc.
3907            assert!(
3908                result.contains(r#"aria-controls="section-1-content""#),
3909                "First accordion section should have aria-controls"
3910            );
3911            assert!(
3912                result.contains(r#"id="section-1-button""#),
3913                "First button should get an ID"
3914            );
3915            assert!(
3916                result.contains(r#"id="section-1-content""#),
3917                "First content should get an ID"
3918            );
3919            assert!(
3920                result.contains(r#"hidden"#),
3921                "Accordion content is hidden by default"
3922            );
3923        }
3924
3925        #[test]
3926        fn test_add_aria_to_accordions_empty() {
3927            let html = r#"<div class="accordion"></div>"#;
3928            let builder = HtmlBuilder::new(html);
3929            let result =
3930                add_aria_to_accordions(builder).unwrap().build();
3931
3932            // If there's no button+div pairs, we just keep the original container
3933            assert!(result.contains(r#"class="accordion""#));
3934            // Shouldn't blow up or panic
3935        }
3936
3937        #[test]
3938        fn test_add_aria_to_tabs_basic() {
3939            // Provide something that has role="tablist" and some <button> inside
3940            let html = r#"
3941        <div role="tablist">
3942            <button>Tab A</button>
3943            <button>Tab B</button>
3944        </div>
3945        "#;
3946            let builder = HtmlBuilder::new(html);
3947            let result = add_aria_to_tabs(builder).unwrap().build();
3948
3949            // We expect tab1 => aria-selected="true", tab2 => aria-selected="false"
3950            assert!(
3951                result.contains(
3952                    r#"role="tab" id="tab1" aria-selected="true""#
3953                ),
3954                "First tab should be tab1, selected=true"
3955            );
3956            assert!(
3957                result.contains(
3958                    r#"role="tab" id="tab2" aria-selected="false""#
3959                ),
3960                "Second tab should be tab2, selected=false"
3961            );
3962            // Also expect the auto-generated panels "panel1" and "panel2"
3963            assert!(
3964                result.contains(r#"aria-controls="panel1""#),
3965                "First tab controls panel1"
3966            );
3967            assert!(
3968                result.contains(r#"id="panel2" role="tabpanel""#),
3969                "Second tab panel should exist"
3970            );
3971        }
3972
3973        /// 9) Test `add_aria_to_tabs` when no tablist is found
3974        #[test]
3975        fn test_add_aria_to_tabs_no_tablist() {
3976            let html = r#"<div><button>Not a tab</button></div>"#;
3977            let builder = HtmlBuilder::new(html);
3978            let result = add_aria_to_tabs(builder).unwrap().build();
3979
3980            // We expect no transformation if there's no role="tablist"
3981            assert!(
3982                result.contains(r#"<button>Not a tab</button>"#),
3983                "Should remain unchanged"
3984            );
3985            assert!(!result.contains(r#"role="tab""#), "No transformation to role=tab if not inside role=tablist");
3986        }
3987
3988        /// 10) Test the `count_checked_elements` function
3989        #[test]
3990        fn test_count_checked_elements() {
3991            let html = r#"
3992        <html>
3993            <body>
3994                <div>
3995                    <p>Paragraph</p>
3996                    <span>Span</span>
3997                </div>
3998            </body>
3999        </html>
4000        "#;
4001            let document = Html::parse_document(html);
4002            let count = count_checked_elements(&document);
4003            // There's 5 elements: <html>, <head> (implicitly empty?), <body>, <div>, <p>, <span> ...
4004            // Actually, <head> might exist only if we parse as a full document, let's see:
4005            // The easiest is to just check the actual number we get. We'll assume 5 or 6.
4006            assert!(
4007                count >= 5,
4008                "Expected at least 5 elements in the parsed tree"
4009            );
4010        }
4011
4012        #[test]
4013        fn test_check_language_attributes_valid() {
4014            let html = r#"<html lang="en"><body></body></html>"#;
4015            let document = Html::parse_document(html);
4016            let mut issues = vec![];
4017            let result = AccessibilityReport::check_language_attributes(
4018                &document,
4019                &mut issues,
4020            );
4021            assert!(result.is_ok());
4022            assert_eq!(issues.len(), 0, "No issues for valid lang");
4023        }
4024
4025        #[test]
4026        fn test_error_variants() {
4027            let _ = Error::InvalidAriaAttribute {
4028                attribute: "aria-bogus".to_string(),
4029                message: "Bogus attribute".to_string(),
4030            };
4031            let _ = Error::WcagValidationError {
4032                level: WcagLevel::AA,
4033                message: "Validation failed".to_string(),
4034                guideline: Some("WCAG 2.4.6".to_string()),
4035            };
4036            let _ = Error::HtmlTooLarge {
4037                size: 9999999,
4038                max_size: 1000000,
4039            };
4040            let _ = Error::HtmlProcessingError {
4041                message: "Something went wrong".to_string(),
4042                source: None,
4043            };
4044            let _ = Error::MalformedHtml {
4045                message: "Broken HTML".to_string(),
4046                fragment: None,
4047            };
4048        }
4049
4050        #[test]
4051        fn test_has_associated_label_no_id() {
4052            let input = r#"<input type="checkbox">"#;
4053            let html =
4054                r#"<label for="checkbox1">Checkbox Label</label>"#;
4055            // There's no id= in the input, so it can't be associated
4056            assert!(
4057                !has_associated_label(input, html),
4058                "No ID => false"
4059            );
4060        }
4061
4062        #[test]
4063        fn test_generate_unique_id_format() {
4064            let new_id = generate_unique_id();
4065            // Should start with "aria-"
4066            assert!(
4067                new_id.starts_with("aria-"),
4068                "Generated ID should start with aria-"
4069            );
4070        }
4071
4072        #[test]
4073        fn test_add_aria_to_buttons_basic_button() {
4074            let html = r#"<button>Click me</button>"#;
4075            let builder = HtmlBuilder::new(html);
4076            let result = add_aria_to_buttons(builder).unwrap().build();
4077
4078            // We expect an `aria-label="click-me"` (normalized from "Click me")
4079            // and no aria-pressed if it wasn't there originally
4080            assert!(
4081                result.contains(r#"aria-label="click-me""#),
4082                "Should add aria-label for normal button text"
4083            );
4084            assert!(
4085                !result.contains(r#"aria-pressed=""#),
4086                "Should not add aria-pressed if not originally present"
4087            );
4088        }
4089
4090        #[test]
4091        fn test_add_aria_to_buttons_disabled() {
4092            let html = r#"<button disabled>Submit</button>"#;
4093            let builder = HtmlBuilder::new(html);
4094            let result = add_aria_to_buttons(builder).unwrap().build();
4095            // Should add aria-disabled="true"
4096            // The label is normalized from "Submit" => "submit"
4097            assert!(
4098                result.contains(r#"aria-disabled="true""#),
4099                "Disabled button should have aria-disabled"
4100            );
4101            assert!(
4102                result.contains(r#"aria-label="submit""#),
4103                "Should have aria-label from button text"
4104            );
4105            // Should not have aria-pressed
4106            assert!(
4107                !result.contains("aria-pressed"),
4108                "Disabled button shouldn't have aria-pressed"
4109            );
4110        }
4111
4112        #[test]
4113        fn test_add_aria_to_buttons_icon_span() {
4114            let html = r#"<button><span class="icon">🔍</span>Search</button>"#;
4115            let builder = HtmlBuilder::new(html);
4116            let result = add_aria_to_buttons(builder).unwrap().build();
4117
4118            // The <span class="icon"> should be updated to <span class="icon" aria-hidden="true">
4119            // The aria-label should come from "left-pointing-magnifying-glass" => "search"
4120            assert!(
4121                result.contains(r#"left-pointing-magnifying-glass""#)
4122            );
4123            assert!(
4124                result.contains(
4125                    r#"<span class="icon" aria-hidden="true">🔍</span>"#
4126                ),
4127                "Icon span should have aria-hidden=\"true\""
4128            );
4129        }
4130
4131        #[test]
4132        fn test_add_aria_to_buttons_toggle_flip() {
4133            // This button already has aria-pressed="true", so our code flips it to "false"
4134            let html = r#"<button aria-pressed="true">Toggle</button>"#;
4135            let builder = HtmlBuilder::new(html);
4136            let result = add_aria_to_buttons(builder).unwrap().build();
4137
4138            // We expect aria-pressed to become "false"
4139            assert!(
4140                result.contains(r#"aria-pressed="false""#),
4141                "Existing aria-pressed=\"true\" should flip to false"
4142            );
4143            // The label is normalized from "Toggle" => "toggle"
4144            assert!(result.contains(r#"aria-label="toggle""#));
4145        }
4146
4147        #[test]
4148        fn test_add_aria_to_buttons_toggle_no_flip() {
4149            // If you want to test preserving the same state, comment out or remove the flipping logic
4150            // in your code. Then you can confirm it remains "true".
4151            // This test is just to illustrate the difference.
4152            let html = r#"<button aria-pressed="true">On</button>"#;
4153            // (Imagine you removed the flipping code.)
4154            // Then you'd check if aria-pressed remains "true".
4155
4156            // We won't fail the test here, but showing how you'd do it:
4157            let builder = HtmlBuilder::new(html);
4158            let result = add_aria_to_buttons(builder).unwrap().build();
4159            // If you had no flipping, you'd do:
4160            // assert!(result.contains(r#"aria-pressed="true""#));
4161
4162            // If your code flips, you'd do:
4163            assert!(result.contains(r#"aria-pressed="false""#));
4164        }
4165
4166        // ---------------------------
4167        // Tests for add_aria_to_toggle
4168        // ---------------------------
4169        #[test]
4170        fn test_add_aria_to_toggle_no_aria_pressed() {
4171            // This div is missing aria-pressed, so we default to "false"
4172            let html = r#"<div class="toggle-button">Click me</div>"#;
4173            let builder = HtmlBuilder::new(html);
4174            let result = add_aria_to_toggle(builder).unwrap().build();
4175
4176            // Expect a <button> with role="button" and aria-pressed="false"
4177            let doc = Html::parse_document(&result);
4178            let selector =
4179                Selector::parse("button.toggle-button").unwrap();
4180            let toggle = doc
4181                .select(&selector)
4182                .next()
4183                .expect("Should have button.toggle-button");
4184            assert_eq!(
4185                toggle.value().attr("aria-pressed"),
4186                Some("false")
4187            );
4188            assert_eq!(toggle.value().attr("role"), Some("button"));
4189            assert_eq!(toggle.inner_html().trim(), "Click me");
4190        }
4191
4192        #[test]
4193        fn test_add_aria_to_toggle_existing_aria_pressed() {
4194            // Already has aria-pressed="true"
4195            let html = r#"<div class="toggle-button" aria-pressed="true">I'm on</div>"#;
4196            let builder = HtmlBuilder::new(html);
4197            let result = add_aria_to_toggle(builder).unwrap().build();
4198
4199            // With the current implementation, the results will be more straightforward
4200            assert!(
4201                result.contains("toggle-button"),
4202                "Should preserve the toggle-button class"
4203            );
4204            assert!(
4205                result.contains("I'm on"),
4206                "Should preserve the content"
4207            );
4208            assert!(
4209                result.contains(r#"aria-pressed="true""#),
4210                "Should preserve aria-pressed value"
4211            );
4212        }
4213
4214        #[test]
4215        fn test_add_aria_to_toggle_preserves_other_attrs() {
4216            let html = r#"<div class="toggle-button" data-role="switch" style="color:red;" aria-pressed="false">Toggle</div>"#;
4217            let builder = HtmlBuilder::new(html);
4218            let result = add_aria_to_toggle(builder).unwrap().build();
4219
4220            // Test for preservation of attributes with current implementation
4221            assert!(
4222                result.contains(r#"class="toggle-button""#),
4223                "Should preserve class"
4224            );
4225            assert!(
4226                result.contains(r#"data-role="switch""#),
4227                "Should preserve data attribute"
4228            );
4229            assert!(
4230                result.contains(r#"style="color:red;""#),
4231                "Should preserve style"
4232            );
4233            assert!(
4234                result.contains(r#"aria-pressed="false""#),
4235                "Should preserve aria-pressed"
4236            );
4237        }
4238
4239        #[test]
4240        fn test_add_aria_to_toggle_no_toggle_elements() {
4241            let html = r#"<div>Just a regular div</div>"#;
4242            let builder = HtmlBuilder::new(html);
4243            let result = add_aria_to_toggle(builder).unwrap().build();
4244            // No .toggle-button => no changes
4245            assert_eq!(
4246                result, html,
4247                "No transformation if there's no .toggle-button"
4248            );
4249        }
4250
4251        #[test]
4252        fn test_has_alert_class_sets_alertdialog() -> Result<()> {
4253            // .alert class (no explicit role) => role="alertdialog"
4254            let original_html = r#"
4255            <div class="modal alert">
4256                <div class="modal-content"><h2>Warning</h2><button>OK</button></div>
4257            </div>
4258        "#;
4259            let builder = HtmlBuilder {
4260                content: original_html.to_string(),
4261            };
4262
4263            let result = add_aria_to_modals(builder)?;
4264            let output = result.content;
4265
4266            // Expect role="alertdialog" and aria-modal="true"
4267            assert!(
4268                output.contains(r#"role="alertdialog""#),
4269                "Expected role=\"alertdialog\" for .alert class"
4270            );
4271            assert!(
4272                output.contains(r#"aria-modal="true""#),
4273                "Expected aria-modal=\"true\" to be set"
4274            );
4275            Ok(())
4276        }
4277
4278        #[test]
4279        fn test_preserves_role_dialog() -> Result<()> {
4280            // If role="dialog" is already set, do not overwrite it.
4281            let original_html = r#"
4282            <div class="modal" role="dialog">
4283                <div class="modal-content"><button>Close</button></div>
4284            </div>
4285        "#;
4286            let builder = HtmlBuilder {
4287                content: original_html.to_string(),
4288            };
4289
4290            let result = add_aria_to_modals(builder)?;
4291            let output = result.content;
4292
4293            // Should preserve the existing role
4294            assert!(
4295                output.contains(r#"role="dialog""#),
4296                "Should preserve role=\"dialog\""
4297            );
4298            // aria-modal should be added if missing
4299            assert!(
4300                output.contains(r#"aria-modal="true""#),
4301                "Expected aria-modal=\"true\" to be added"
4302            );
4303            Ok(())
4304        }
4305
4306        #[test]
4307        fn test_preserves_role_alertdialog() -> Result<()> {
4308            // If role="alertdialog" is already set, do not overwrite it.
4309            let original_html = r#"
4310            <div class="modal" role="alertdialog">
4311                <div class="modal-content"><h2>Warning</h2></div>
4312            </div>
4313        "#;
4314            let builder = HtmlBuilder {
4315                content: original_html.to_string(),
4316            };
4317
4318            let result = add_aria_to_modals(builder)?;
4319            let output = result.content;
4320
4321            // Should preserve the existing role
4322            assert!(
4323                output.contains(r#"role="alertdialog""#),
4324                "Should preserve role=\"alertdialog\""
4325            );
4326            // aria-modal should be added if missing
4327            assert!(
4328                output.contains(r#"aria-modal="true""#),
4329                "Expected aria-modal=\"true\" to be added"
4330            );
4331            Ok(())
4332        }
4333
4334        #[test]
4335        fn test_already_has_aria_modal_does_not_duplicate() -> Result<()>
4336        {
4337            // Already has aria-modal="true", do not duplicate it
4338            let original_html = r#"
4339            <div class="modal" role="dialog" aria-modal="true">
4340                <div class="modal-content"><button>Close</button></div>
4341            </div>
4342        "#;
4343            let builder = HtmlBuilder {
4344                content: original_html.to_string(),
4345            };
4346
4347            let result = add_aria_to_modals(builder)?;
4348            let output = result.content;
4349
4350            // Should still have aria-modal="true", but not repeated
4351            // (just ensure it occurs once)
4352            let count = output.matches(r#"aria-modal="true""#).count();
4353            assert_eq!(
4354                count, 1,
4355                "aria-modal=\"true\" should only appear once"
4356            );
4357            Ok(())
4358        }
4359
4360        #[test]
4361        fn test_adds_aria_describedby_for_dialog_description(
4362        ) -> Result<()> {
4363            // .dialog-description triggers aria-describedby
4364            let original_html = r#"
4365            <div class="modal">
4366                <div class="dialog-description">This is an important message</div>
4367                <div class="modal-content"><button>Close</button></div>
4368            </div>
4369        "#;
4370            let builder = HtmlBuilder {
4371                content: original_html.to_string(),
4372            };
4373
4374            let result = add_aria_to_modals(builder)?;
4375            let output = result.content;
4376
4377            // Should have aria-modal="true" and role="dialog" if missing
4378            assert!(
4379                output.contains(r#"role="dialog""#),
4380                "Expected role=\"dialog\""
4381            );
4382            assert!(
4383                output.contains(r#"aria-modal="true""#),
4384                "Expected aria-modal=\"true\""
4385            );
4386
4387            // Should have an aria-describedby attribute referencing the .dialog-description
4388            // The ID may be auto-generated if it didn't exist
4389            let has_aria_describedby =
4390                output.contains("aria-describedby=");
4391            assert!(
4392            has_aria_describedby,
4393            "Should have aria-describedby referencing the .dialog-description"
4394        );
4395            Ok(())
4396        }
4397
4398        #[test]
4399        fn test_dialog_description_missing_does_not_add_aria_describedby(
4400        ) -> Result<()> {
4401            // If there's no descriptive element, no aria-describedby should be added
4402            let original_html = r#"
4403            <div class="modal">
4404                <div class="modal-content"><button>Close</button></div>
4405            </div>
4406        "#;
4407
4408            let builder = HtmlBuilder {
4409                content: original_html.to_string(),
4410            };
4411
4412            let result = add_aria_to_modals(builder)?;
4413            let output = result.content;
4414
4415            // Should not contain aria-describedby
4416            assert!(
4417            !output.contains("aria-describedby="),
4418            "Should not add aria-describedby if no descriptive element is found"
4419        );
4420            Ok(())
4421        }
4422
4423        #[test]
4424        fn test_paragraph_as_dialog_description() -> Result<()> {
4425            // If there's a <p> tag inside, it should also trigger aria-describedby
4426            let original_html = r#"
4427            <div class="modal">
4428                <p>This is a brief description of the dialog.</p>
4429                <div class="modal-content"><button>Close</button></div>
4430            </div>
4431        "#;
4432
4433            let builder = HtmlBuilder {
4434                content: original_html.to_string(),
4435            };
4436
4437            let result = add_aria_to_modals(builder)?;
4438            let output = result.content;
4439
4440            // The <p> should be assigned an auto-generated ID if it doesn't have one.
4441            // So we expect something like: <p id="dialog-desc-XXXXXX"> ...
4442            // Then aria-describedby="dialog-desc-XXXXXX" on the .modal
4443            assert!(
4444                output.contains("aria-describedby="),
4445                "Should have aria-describedby referencing the <p>"
4446            );
4447            assert!(
4448                output.contains("id=\"dialog-desc-"),
4449                "Should have auto-generated ID assigned to <p>"
4450            );
4451            Ok(())
4452        }
4453    }
4454}