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