use crate::accessibility::utils::get_missing_required_aria_properties;
use crate::accessibility::utils::is_valid_aria_role;
use crate::accessibility::utils::is_valid_language_code;
use once_cell::sync::Lazy;
use regex::Regex;
use scraper::{Html, Selector};
use std::collections::HashSet;
use std::sync::atomic::{AtomicUsize, Ordering};
use thiserror::Error;
pub mod constants {
pub const MAX_HTML_SIZE: usize = 1_000_000;
pub const DEFAULT_NAV_ROLE: &str = "navigation";
pub const DEFAULT_BUTTON_ROLE: &str = "button";
pub const DEFAULT_FORM_ROLE: &str = "form";
pub const DEFAULT_INPUT_ROLE: &str = "textbox";
}
static COUNTER: AtomicUsize = AtomicUsize::new(0);
use constants::{
DEFAULT_BUTTON_ROLE, DEFAULT_INPUT_ROLE, DEFAULT_NAV_ROLE,
MAX_HTML_SIZE,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WcagLevel {
A,
AA,
AAA,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueType {
MissingAltText,
HeadingStructure,
MissingLabels,
InvalidAria,
ColorContrast,
KeyboardNavigation,
LanguageDeclaration,
}
#[derive(Debug, Error)]
pub enum Error {
#[error("Invalid ARIA Attribute '{attribute}': {message}")]
InvalidAriaAttribute {
attribute: String,
message: String,
},
#[error("WCAG {level} Validation Error: {message}")]
WcagValidationError {
level: WcagLevel,
message: String,
guideline: Option<String>,
},
#[error(
"HTML Input Too Large: size {size} exceeds maximum {max_size}"
)]
HtmlTooLarge {
size: usize,
max_size: usize,
},
#[error("HTML Processing Error: {message}")]
HtmlProcessingError {
message: String,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("Malformed HTML: {message}")]
MalformedHtml {
message: String,
fragment: Option<String>,
},
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone)]
pub struct Issue {
pub issue_type: IssueType,
pub message: String,
pub guideline: Option<String>,
pub element: Option<String>,
pub suggestion: Option<String>,
}
fn try_create_selector(selector: &str) -> Option<Selector> {
match Selector::parse(selector) {
Ok(s) => Some(s),
Err(e) => {
eprintln!(
"Failed to create selector '{}': {}",
selector, e
);
None
}
}
}
fn try_create_regex(pattern: &str) -> Option<Regex> {
match Regex::new(pattern) {
Ok(r) => Some(r),
Err(e) => {
eprintln!("Failed to create regex '{}': {}", pattern, e);
None
}
}
}
static BUTTON_SELECTOR: Lazy<Option<Selector>> =
Lazy::new(|| try_create_selector("button:not([aria-label])"));
static NAV_SELECTOR: Lazy<Option<Selector>> =
Lazy::new(|| try_create_selector("nav:not([aria-label])"));
static FORM_SELECTOR: Lazy<Option<Selector>> =
Lazy::new(|| try_create_selector("form:not([aria-labelledby])"));
static INPUT_REGEX: Lazy<Option<Regex>> =
Lazy::new(|| try_create_regex(r"<input[^>]*>"));
static ARIA_SELECTOR: Lazy<Option<Selector>> = Lazy::new(|| {
try_create_selector(concat!(
"[aria-label], [aria-labelledby], [aria-describedby], ",
"[aria-hidden], [aria-expanded], [aria-haspopup], ",
"[aria-controls], [aria-pressed], [aria-checked], ",
"[aria-current], [aria-disabled], [aria-dropeffect], ",
"[aria-grabbed], [aria-invalid], [aria-live], ",
"[aria-owns], [aria-relevant], [aria-required], ",
"[aria-role], [aria-selected], [aria-valuemax], ",
"[aria-valuemin], [aria-valuenow], [aria-valuetext]"
))
});
static VALID_ARIA_ATTRIBUTES: Lazy<HashSet<&'static str>> =
Lazy::new(|| {
[
"aria-label",
"aria-labelledby",
"aria-describedby",
"aria-hidden",
"aria-expanded",
"aria-haspopup",
"aria-controls",
"aria-pressed",
"aria-checked",
"aria-current",
"aria-disabled",
"aria-dropeffect",
"aria-grabbed",
"aria-invalid",
"aria-live",
"aria-owns",
"aria-relevant",
"aria-required",
"aria-role",
"aria-selected",
"aria-valuemax",
"aria-valuemin",
"aria-valuenow",
"aria-valuetext",
]
.iter()
.copied()
.collect()
});
#[derive(Debug, Copy, Clone)]
pub struct AccessibilityConfig {
pub wcag_level: WcagLevel,
pub max_heading_jump: u8,
pub min_contrast_ratio: f64,
pub auto_fix: bool,
}
impl Default for AccessibilityConfig {
fn default() -> Self {
Self {
wcag_level: WcagLevel::AA,
max_heading_jump: 1,
min_contrast_ratio: 4.5, auto_fix: true,
}
}
}
#[derive(Debug)]
pub struct AccessibilityReport {
pub issues: Vec<Issue>,
pub wcag_level: WcagLevel,
pub elements_checked: usize,
pub issue_count: usize,
pub check_duration_ms: u64,
}
pub fn add_aria_attributes(
html: &str,
config: Option<AccessibilityConfig>,
) -> Result<String> {
let config = config.unwrap_or_default();
if html.len() > MAX_HTML_SIZE {
return Err(Error::HtmlTooLarge {
size: html.len(),
max_size: MAX_HTML_SIZE,
});
}
let mut html_builder = HtmlBuilder::new(html);
html_builder = add_aria_to_buttons(html_builder)?;
html_builder = add_aria_to_navs(html_builder)?;
html_builder = add_aria_to_forms(html_builder)?;
html_builder = add_aria_to_inputs(html_builder)?;
if matches!(config.wcag_level, WcagLevel::AA | WcagLevel::AAA) {
html_builder = enhance_landmarks(html_builder)?;
html_builder = add_live_regions(html_builder)?;
}
if matches!(config.wcag_level, WcagLevel::AAA) {
html_builder = enhance_descriptions(html_builder)?;
}
let new_html =
remove_invalid_aria_attributes(&html_builder.build());
if !validate_aria(&new_html) {
return Err(Error::InvalidAriaAttribute {
attribute: "multiple".to_string(),
message: "Failed to add valid ARIA attributes".to_string(),
});
}
Ok(new_html)
}
#[derive(Debug, Clone)]
struct HtmlBuilder {
content: String,
}
impl HtmlBuilder {
fn new(initial_content: &str) -> Self {
HtmlBuilder {
content: initial_content.to_string(),
}
}
fn build(self) -> String {
self.content
}
}
fn count_checked_elements(document: &Html) -> usize {
document.select(&Selector::parse("*").unwrap()).count()
}
const fn enhance_landmarks(
html_builder: HtmlBuilder,
) -> Result<HtmlBuilder> {
Ok(html_builder)
}
const fn add_live_regions(
html_builder: HtmlBuilder,
) -> Result<HtmlBuilder> {
Ok(html_builder)
}
const fn enhance_descriptions(
html_builder: HtmlBuilder,
) -> Result<HtmlBuilder> {
Ok(html_builder)
}
fn check_heading_structure(document: &Html, issues: &mut Vec<Issue>) {
let mut prev_level: Option<u8> = None;
let selector = match Selector::parse("h1, h2, h3, h4, h5, h6") {
Ok(selector) => selector,
Err(e) => {
eprintln!("Failed to parse selector: {}", e);
return; }
};
for heading in document.select(&selector) {
let current_level = heading
.value()
.name()
.chars()
.nth(1)
.and_then(|c| c.to_digit(10))
.and_then(|n| u8::try_from(n).ok());
if let Some(current_level) = current_level {
if let Some(prev_level) = prev_level {
if current_level > prev_level + 1 {
issues.push(Issue {
issue_type: IssueType::HeadingStructure,
message: format!(
"Skipped heading level from h{} to h{}",
prev_level, current_level
),
guideline: Some("WCAG 2.4.6".to_string()),
element: Some(heading.html()),
suggestion: Some(
"Use sequential heading levels".to_string(),
),
});
}
}
prev_level = Some(current_level);
}
}
}
pub fn validate_wcag(
html: &str,
config: &AccessibilityConfig,
disable_checks: Option<&[IssueType]>,
) -> Result<AccessibilityReport> {
let start_time = std::time::Instant::now();
let mut issues = Vec::new();
let mut elements_checked = 0;
if html.trim().is_empty() {
return Ok(AccessibilityReport {
issues: Vec::new(),
wcag_level: config.wcag_level,
elements_checked: 0,
issue_count: 0,
check_duration_ms: 0,
});
}
let document = Html::parse_document(html);
if disable_checks
.map_or(true, |d| !d.contains(&IssueType::LanguageDeclaration))
{
check_language_attributes(&document, &mut issues)?; }
check_heading_structure(&document, &mut issues);
elements_checked += count_checked_elements(&document);
let check_duration_ms = u64::try_from(
start_time.elapsed().as_millis(),
)
.map_err(|err| Error::HtmlProcessingError {
message: "Failed to convert duration to milliseconds"
.to_string(),
source: Some(Box::new(err)),
})?;
Ok(AccessibilityReport {
issues: issues.clone(),
wcag_level: config.wcag_level,
elements_checked,
issue_count: issues.len(),
check_duration_ms,
})
}
impl From<std::num::TryFromIntError> for Error {
fn from(err: std::num::TryFromIntError) -> Self {
Error::HtmlProcessingError {
message: "Integer conversion error".to_string(),
source: Some(Box::new(err)),
}
}
}
impl std::fmt::Display for WcagLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WcagLevel::A => write!(f, "A"),
WcagLevel::AA => write!(f, "AA"),
WcagLevel::AAA => write!(f, "AAA"),
}
}
}
impl AccessibilityReport {
fn add_issue(
issues: &mut Vec<Issue>,
issue_type: IssueType,
message: impl Into<String>,
guideline: Option<String>,
element: Option<String>,
suggestion: Option<String>,
) {
issues.push(Issue {
issue_type,
message: message.into(),
guideline,
element,
suggestion,
});
}
}
fn add_aria_to_buttons(
mut html_builder: HtmlBuilder,
) -> Result<HtmlBuilder> {
let document = Html::parse_document(&html_builder.content);
if let Some(selector) = BUTTON_SELECTOR.as_ref() {
for button in document.select(selector) {
if button.value().attr("aria-label").is_none() {
let button_html = button.html();
let inner_content = button.inner_html();
let new_button_html = if inner_content.trim().is_empty()
{
format!(
r#"<button aria-label="{}" role="button">{}</button>"#,
DEFAULT_BUTTON_ROLE, inner_content
)
} else {
format!(
r#"<button aria-label="{}" role="button">{}</button>"#,
inner_content.trim(),
inner_content
)
};
html_builder.content = html_builder
.content
.replace(&button_html, &new_button_html);
}
}
}
Ok(html_builder)
}
fn add_aria_to_navs(
mut html_builder: HtmlBuilder,
) -> Result<HtmlBuilder> {
let document = Html::parse_document(&html_builder.content);
if let Some(selector) = NAV_SELECTOR.as_ref() {
for nav in document.select(selector) {
let nav_html = nav.html();
let new_nav_html = nav_html.replace(
"<nav",
&format!(
r#"<nav aria-label="{}" role="navigation""#,
DEFAULT_NAV_ROLE
),
);
html_builder.content =
html_builder.content.replace(&nav_html, &new_nav_html);
}
}
Ok(html_builder)
}
fn add_aria_to_forms(
mut html_builder: HtmlBuilder,
) -> Result<HtmlBuilder> {
let document = Html::parse_document(&html_builder.content);
if let Some(selector) = FORM_SELECTOR.as_ref() {
for form in document.select(selector) {
let form_html = form.html();
let form_id = format!("form-{}", generate_unique_id());
let new_form_html = form_html.replace(
"<form",
&format!(
r#"<form id="{}" aria-labelledby="{}" role="form""#,
form_id, form_id
),
);
html_builder.content = html_builder
.content
.replace(&form_html, &new_form_html);
}
}
Ok(html_builder)
}
fn add_aria_to_inputs(
mut html_builder: HtmlBuilder,
) -> Result<HtmlBuilder> {
if let Some(regex) = INPUT_REGEX.as_ref() {
let mut replacements: Vec<(String, String)> = Vec::new();
for cap in regex.captures_iter(&html_builder.content) {
let input_tag = &cap[0];
if !input_tag.contains("aria-label") {
let input_type = extract_input_type(input_tag)
.unwrap_or_else(|| "text".to_string());
let new_input_tag = format!(
r#"<input aria-label="{}" role="{}" type="{}""#,
input_type, DEFAULT_INPUT_ROLE, input_type
);
replacements
.push((input_tag.to_string(), new_input_tag));
}
}
for (old, new) in replacements {
html_builder.content =
html_builder.content.replace(&old, &new);
}
}
Ok(html_builder)
}
fn extract_input_type(input_tag: &str) -> Option<String> {
static TYPE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r#"type=["']([^"']+)["']"#)
.expect("Failed to create type regex")
});
TYPE_REGEX
.captures(input_tag)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
}
fn generate_unique_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
let count = COUNTER.fetch_add(1, Ordering::SeqCst);
format!("aria-{}-{}", nanos, count)
}
fn validate_aria(html: &str) -> bool {
let document = Html::parse_document(html);
if let Some(selector) = ARIA_SELECTOR.as_ref() {
document
.select(selector)
.flat_map(|el| el.value().attrs())
.filter(|(name, _)| name.starts_with("aria-"))
.all(|(name, value)| is_valid_aria_attribute(name, value))
} else {
eprintln!("ARIA_SELECTOR failed to initialize.");
false
}
}
fn remove_invalid_aria_attributes(html: &str) -> String {
let document = Html::parse_document(html);
let mut new_html = html.to_string();
if let Some(selector) = ARIA_SELECTOR.as_ref() {
for element in document.select(selector) {
let element_html = element.html();
let mut updated_html = element_html.clone();
for (attr_name, attr_value) in element.value().attrs() {
if attr_name.starts_with("aria-")
&& !is_valid_aria_attribute(attr_name, attr_value)
{
updated_html = updated_html.replace(
&format!(r#" {}="{}""#, attr_name, attr_value),
"",
);
}
}
new_html = new_html.replace(&element_html, &updated_html);
}
}
new_html
}
fn is_valid_aria_attribute(name: &str, value: &str) -> bool {
if !VALID_ARIA_ATTRIBUTES.contains(name) {
return false; }
match name {
"aria-hidden" | "aria-expanded" | "aria-pressed"
| "aria-invalid" => {
matches!(value, "true" | "false") }
"aria-level" => value.parse::<u32>().is_ok(), _ => !value.trim().is_empty(), }
}
fn check_language_attributes(
document: &Html,
issues: &mut Vec<Issue>,
) -> Result<()> {
if let Some(html_element) =
document.select(&Selector::parse("html").unwrap()).next()
{
if html_element.value().attr("lang").is_none() {
AccessibilityReport::add_issue(
issues,
IssueType::LanguageDeclaration,
"Missing language declaration on HTML element",
Some("WCAG 3.1.1".to_string()),
Some("<html>".to_string()),
Some("Add lang attribute to HTML element".to_string()),
);
}
}
for element in document.select(&Selector::parse("[lang]").unwrap())
{
if let Some(lang) = element.value().attr("lang") {
if !is_valid_language_code(lang) {
AccessibilityReport::add_issue(
issues,
IssueType::LanguageDeclaration,
format!("Invalid language code: {}", lang),
Some("WCAG 3.1.2".to_string()),
Some(element.html()),
Some("Use valid BCP 47 language code".to_string()),
);
}
}
}
Ok(())
}
impl AccessibilityReport {
pub fn check_keyboard_navigation(
document: &Html,
issues: &mut Vec<Issue>,
) -> Result<()> {
let binding = Selector::parse(
"a, button, input, select, textarea, [tabindex]",
)
.unwrap();
let interactive_elements = document.select(&binding);
for element in interactive_elements {
if let Some(tabindex) = element.value().attr("tabindex") {
if let Ok(index) = tabindex.parse::<i32>() {
if index < 0 {
issues.push(Issue {
issue_type: IssueType::KeyboardNavigation,
message: "Negative tabindex prevents keyboard focus".to_string(),
guideline: Some("WCAG 2.1.1".to_string()),
element: Some(element.html()),
suggestion: Some("Remove negative tabindex value".to_string()),
});
}
}
}
if element.value().attr("onclick").is_some()
&& element.value().attr("onkeypress").is_none()
&& element.value().attr("onkeydown").is_none()
{
issues.push(Issue {
issue_type: IssueType::KeyboardNavigation,
message:
"Click handler without keyboard equivalent"
.to_string(),
guideline: Some("WCAG 2.1.1".to_string()),
element: Some(element.html()),
suggestion: Some(
"Add keyboard event handlers".to_string(),
),
});
}
}
Ok(())
}
pub fn check_language_attributes(
document: &Html,
issues: &mut Vec<Issue>,
) -> Result<()> {
let html_element =
document.select(&Selector::parse("html").unwrap()).next();
if let Some(element) = html_element {
if element.value().attr("lang").is_none() {
Self::add_issue(
issues,
IssueType::LanguageDeclaration,
"Missing language declaration",
Some("WCAG 3.1.1".to_string()),
Some(element.html()),
Some(
"Add lang attribute to html element"
.to_string(),
),
);
}
}
let binding = Selector::parse("[lang]").unwrap();
let text_elements = document.select(&binding);
for element in text_elements {
if let Some(lang) = element.value().attr("lang") {
if !is_valid_language_code(lang) {
Self::add_issue(
issues,
IssueType::LanguageDeclaration,
format!("Invalid language code: {}", lang),
Some("WCAG 3.1.2".to_string()),
Some(element.html()),
Some(
"Use valid BCP 47 language code"
.to_string(),
),
);
}
}
}
Ok(())
}
pub fn check_advanced_aria(
document: &Html,
issues: &mut Vec<Issue>,
) -> Result<()> {
let binding = Selector::parse("[role]").unwrap();
let elements_with_roles = document.select(&binding);
for element in elements_with_roles {
if let Some(role) = element.value().attr("role") {
if !is_valid_aria_role(role, &element) {
Self::add_issue(
issues,
IssueType::InvalidAria,
format!(
"Invalid ARIA role '{}' for element",
role
),
Some("WCAG 4.1.2".to_string()),
Some(element.html()),
Some("Use appropriate ARIA role".to_string()),
);
}
}
}
let elements_with_aria =
document.select(ARIA_SELECTOR.as_ref().unwrap());
for element in elements_with_aria {
if let Some(missing_props) =
get_missing_required_aria_properties(&element)
{
Self::add_issue(
issues,
IssueType::InvalidAria,
format!(
"Missing required ARIA properties: {}",
missing_props.join(", ")
),
Some("WCAG 4.1.2".to_string()),
Some(element.html()),
Some("Add required ARIA properties".to_string()),
);
}
}
Ok(())
}
}
pub mod utils {
use scraper::ElementRef;
use std::collections::HashMap;
use once_cell::sync::Lazy;
use regex::Regex;
pub(crate) fn is_valid_language_code(lang: &str) -> bool {
static LANGUAGE_CODE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^[a-z]{2,3}(-[a-z0-9]{2,8})*$").unwrap()
});
LANGUAGE_CODE_REGEX.is_match(lang) && !lang.ends_with('-')
}
pub(crate) fn is_valid_aria_role(
role: &str,
element: &ElementRef,
) -> bool {
static VALID_ROLES: Lazy<HashMap<&str, Vec<&str>>> =
Lazy::new(|| {
let mut map = HashMap::new();
_ = map.insert(
"button",
vec!["button", "link", "menuitem"],
);
_ = map.insert(
"input",
vec!["textbox", "radio", "checkbox", "button"],
);
_ = map.insert(
"div",
vec!["alert", "tooltip", "dialog", "slider"],
);
_ = map.insert("a", vec!["link", "button", "menuitem"]);
map
});
let tag_name = element.value().name();
if ["div", "span", "a"].contains(&tag_name) {
return true;
}
if let Some(valid_roles) = VALID_ROLES.get(tag_name) {
valid_roles.contains(&role)
} else {
false
}
}
pub(crate) fn get_missing_required_aria_properties(
element: &ElementRef,
) -> Option<Vec<String>> {
let mut missing = Vec::new();
static REQUIRED_ARIA_PROPS: Lazy<HashMap<&str, Vec<&str>>> =
Lazy::new(|| {
HashMap::from([
(
"slider",
vec![
"aria-valuenow",
"aria-valuemin",
"aria-valuemax",
],
),
("combobox", vec!["aria-expanded"]),
])
});
if let Some(role) = element.value().attr("role") {
if let Some(required_props) = REQUIRED_ARIA_PROPS.get(role)
{
for prop in required_props {
if element.value().attr(prop).is_none() {
missing.push(prop.to_string());
}
}
}
}
if missing.is_empty() {
None
} else {
Some(missing)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
mod wcag_level_tests {
use super::*;
#[test]
fn test_wcag_level_ordering() {
assert!(matches!(WcagLevel::A, WcagLevel::A));
assert!(matches!(WcagLevel::AA, WcagLevel::AA));
assert!(matches!(WcagLevel::AAA, WcagLevel::AAA));
}
#[test]
fn test_wcag_level_debug() {
assert_eq!(format!("{:?}", WcagLevel::A), "A");
assert_eq!(format!("{:?}", WcagLevel::AA), "AA");
assert_eq!(format!("{:?}", WcagLevel::AAA), "AAA");
}
}
mod config_tests {
use super::*;
#[test]
fn test_default_config() {
let config = AccessibilityConfig::default();
assert_eq!(config.wcag_level, WcagLevel::AA);
assert_eq!(config.max_heading_jump, 1);
assert_eq!(config.min_contrast_ratio, 4.5);
assert!(config.auto_fix);
}
#[test]
fn test_custom_config() {
let config = AccessibilityConfig {
wcag_level: WcagLevel::AAA,
max_heading_jump: 2,
min_contrast_ratio: 7.0,
auto_fix: false,
};
assert_eq!(config.wcag_level, WcagLevel::AAA);
assert_eq!(config.max_heading_jump, 2);
assert_eq!(config.min_contrast_ratio, 7.0);
assert!(!config.auto_fix);
}
}
mod aria_attribute_tests {
use super::*;
#[test]
fn test_valid_aria_attributes() {
assert!(is_valid_aria_attribute("aria-label", "Test"));
assert!(is_valid_aria_attribute("aria-hidden", "true"));
assert!(is_valid_aria_attribute("aria-hidden", "false"));
assert!(!is_valid_aria_attribute("aria-hidden", "yes"));
assert!(!is_valid_aria_attribute("invalid-aria", "value"));
}
#[test]
fn test_empty_aria_value() {
assert!(!is_valid_aria_attribute("aria-label", ""));
assert!(!is_valid_aria_attribute("aria-label", " "));
}
}
mod html_modification_tests {
use super::*;
#[test]
fn test_add_aria_to_button() {
let html = "<button>Click me</button>";
let result = add_aria_attributes(html, None);
assert!(result.is_ok());
let enhanced = result.unwrap();
assert!(enhanced.contains(r#"aria-label="Click me""#));
assert!(enhanced.contains(r#"role="button""#));
}
#[test]
fn test_add_aria_to_empty_button() {
let html = "<button></button>";
let result = add_aria_attributes(html, None);
assert!(result.is_ok());
let enhanced = result.unwrap();
assert!(enhanced.contains(r#"aria-label="button""#));
}
#[test]
fn test_large_input() {
let large_html = "a".repeat(MAX_HTML_SIZE + 1);
let result = add_aria_attributes(&large_html, None);
assert!(matches!(result, Err(Error::HtmlTooLarge { .. })));
}
}
mod validation_tests {
use super::*;
#[test]
fn test_heading_structure() {
let valid_html = "<h1>Main Title</h1><h2>Subtitle</h2>";
let invalid_html =
"<h1>Main Title</h1><h3>Skipped Heading</h3>";
let config = AccessibilityConfig::default();
let valid_result = validate_wcag(
valid_html,
&config,
Some(&[IssueType::LanguageDeclaration]),
)
.unwrap();
assert_eq!(
valid_result.issue_count, 0,
"Expected no issues for valid HTML, but found: {:#?}",
valid_result.issues
);
let invalid_result = validate_wcag(
invalid_html,
&config,
Some(&[IssueType::LanguageDeclaration]),
)
.unwrap();
assert_eq!(
invalid_result.issue_count,
1,
"Expected one issue for skipped heading levels, but found: {:#?}",
invalid_result.issues
);
let issue = &invalid_result.issues[0];
assert_eq!(issue.issue_type, IssueType::HeadingStructure);
assert_eq!(
issue.message,
"Skipped heading level from h1 to h3"
);
assert_eq!(issue.guideline, Some("WCAG 2.4.6".to_string()));
assert_eq!(
issue.suggestion,
Some("Use sequential heading levels".to_string())
);
}
}
mod report_tests {
use super::*;
#[test]
fn test_report_generation() {
let html = r#"<img src="test.jpg">"#;
let config = AccessibilityConfig::default();
let report = validate_wcag(html, &config, None).unwrap();
assert!(report.issue_count > 0);
assert_eq!(report.wcag_level, WcagLevel::AA);
}
#[test]
fn test_empty_html_report() {
let html = "";
let config = AccessibilityConfig::default();
let report = validate_wcag(html, &config, None).unwrap();
assert_eq!(report.elements_checked, 0);
assert_eq!(report.issue_count, 0);
}
#[test]
fn test_missing_selector_handling() {
static TEST_NAV_SELECTOR: Lazy<Option<Selector>> =
Lazy::new(|| None);
let html = "<nav>Main Navigation</nav>";
let document = Html::parse_document(html);
if let Some(selector) = TEST_NAV_SELECTOR.as_ref() {
let navs: Vec<_> = document.select(selector).collect();
assert_eq!(navs.len(), 0);
}
}
#[test]
fn test_html_processing_error_with_source() {
let source_error = std::io::Error::new(
std::io::ErrorKind::Other,
"test source error",
);
let error = Error::HtmlProcessingError {
message: "Processing failed".to_string(),
source: Some(Box::new(source_error)),
};
assert_eq!(
format!("{}", error),
"HTML Processing Error: Processing failed"
);
}
}
#[cfg(test)]
mod utils_tests {
use super::*;
mod language_code_validation {
use super::*;
#[test]
fn test_valid_language_codes() {
let valid_codes = [
"en", "en-US", "zh-CN", "fr-FR", "de-DE", "es-419",
"ar-001", "pt-BR", "ja-JP", "ko-KR",
];
for code in valid_codes {
assert!(
is_valid_language_code(code),
"Language code '{}' should be valid",
code
);
}
}
#[test]
fn test_invalid_language_codes() {
let invalid_codes = [
"", "a", "123", "en_US", "en-", "-en", "en--US", "toolong", "en-US-INVALID-", ];
for code in invalid_codes {
assert!(
!is_valid_language_code(code),
"Language code '{}' should be invalid",
code
);
}
}
#[test]
fn test_language_code_case_sensitivity() {
assert!(is_valid_language_code("en-GB"));
assert!(is_valid_language_code("fr-FR"));
assert!(is_valid_language_code("zh-Hans"));
assert!(is_valid_language_code("EN-GB"));
}
}
mod aria_role_validation {
use super::*;
#[test]
fn test_valid_button_roles() {
let html = "<button>Test</button>";
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("button").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let valid_roles = ["button", "link", "menuitem"];
for role in valid_roles {
assert!(
is_valid_aria_role(role, &element),
"Role '{}' should be valid for button",
role
);
}
}
#[test]
fn test_valid_input_roles() {
let html = "<input type='text'>";
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("input").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let valid_roles =
["textbox", "radio", "checkbox", "button"];
for role in valid_roles {
assert!(
is_valid_aria_role(role, &element),
"Role '{}' should be valid for input",
role
);
}
}
#[test]
fn test_valid_anchor_roles() {
let html = "<a href=\"\\#\">Test</a>";
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("a").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let valid_roles = ["button", "link", "menuitem"];
for role in valid_roles {
assert!(
is_valid_aria_role(role, &element),
"Role '{}' should be valid for anchor",
role
);
}
}
#[test]
fn test_invalid_element_roles() {
let html = "<button>Test</button>";
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("button").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let invalid_roles =
["textbox", "radio", "checkbox", "invalid"];
for role in invalid_roles {
assert!(
!is_valid_aria_role(role, &element),
"Role '{}' should be invalid for button",
role
);
}
}
#[test]
fn test_unrestricted_elements() {
let html_div = "<div>Test</div>";
let fragment_div = Html::parse_fragment(html_div);
let selector_div = Selector::parse("div").unwrap();
let element_div =
fragment_div.select(&selector_div).next().unwrap();
let html_span = "<span>Test</span>";
let fragment_span = Html::parse_fragment(html_span);
let selector_span = Selector::parse("span").unwrap();
let element_span = fragment_span
.select(&selector_span)
.next()
.unwrap();
let roles =
["button", "textbox", "navigation", "banner"];
for role in roles {
assert!(
is_valid_aria_role(role, &element_div),
"Role '{}' should be allowed for div",
role
);
assert!(
is_valid_aria_role(role, &element_span),
"Role '{}' should be allowed for span",
role
);
}
}
#[test]
fn test_validate_wcag_with_level_aaa() {
let html =
"<h1>Main Title</h1><h3>Skipped Heading</h3>";
let config = AccessibilityConfig {
wcag_level: WcagLevel::AAA,
..Default::default()
};
let report =
validate_wcag(html, &config, None).unwrap();
assert!(report.issue_count > 0);
assert_eq!(report.wcag_level, WcagLevel::AAA);
}
#[test]
fn test_html_builder_empty() {
let builder = HtmlBuilder::new("");
assert_eq!(builder.build(), "");
}
#[test]
fn test_generate_unique_id_uniqueness() {
let id1 = generate_unique_id();
let id2 = generate_unique_id();
assert_ne!(id1, id2);
}
}
mod required_aria_properties {
use super::*;
use scraper::{Html, Selector};
#[test]
fn test_combobox_required_properties() {
let html = r#"<div role="combobox">Test</div>"#;
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("div").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let missing =
get_missing_required_aria_properties(&element)
.unwrap();
assert!(missing.contains(&"aria-expanded".to_string()));
}
#[test]
fn test_complete_combobox() {
let html = r#"<div role="combobox" aria-expanded="true">Test</div>"#;
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("div").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let missing =
get_missing_required_aria_properties(&element);
assert!(missing.is_none());
}
#[test]
fn test_add_aria_attributes_empty_html() {
let html = "";
let result = add_aria_attributes(html, None);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "");
}
#[test]
fn test_add_aria_attributes_whitespace_html() {
let html = " ";
let result = add_aria_attributes(html, None);
assert!(result.is_ok());
assert_eq!(result.unwrap(), " ");
}
#[test]
fn test_validate_wcag_with_minimal_config() {
let html = r#"<html lang="en"><div>Accessible Content</div></html>"#;
let config = AccessibilityConfig {
wcag_level: WcagLevel::A,
max_heading_jump: 0, min_contrast_ratio: 0.0, auto_fix: false,
};
let report =
validate_wcag(html, &config, None).unwrap();
assert_eq!(report.issue_count, 0);
}
#[test]
fn test_add_partial_aria_attributes_to_button() {
let html =
r#"<button aria-label="Existing">Click</button>"#;
let result = add_aria_attributes(html, None);
assert!(result.is_ok());
let enhanced = result.unwrap();
assert!(enhanced.contains(r#"aria-label="Existing""#));
}
#[test]
fn test_add_aria_to_elements_with_existing_roles() {
let html = r#"<nav aria-label=\"navigation\" role=\"navigation\" role=\"navigation\">Content</nav>"#;
let result = add_aria_attributes(html, None);
assert!(result.is_ok());
assert_eq!(result.unwrap(), html);
}
#[test]
fn test_slider_required_properties() {
let html = r#"<div role="slider">Test</div>"#;
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("div").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let missing =
get_missing_required_aria_properties(&element)
.unwrap();
assert!(missing.contains(&"aria-valuenow".to_string()));
assert!(missing.contains(&"aria-valuemin".to_string()));
assert!(missing.contains(&"aria-valuemax".to_string()));
}
#[test]
fn test_complete_slider() {
let html = r#"<div role="slider"
aria-valuenow="50"
aria-valuemin="0"
aria-valuemax="100">Test</div>"#;
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("div").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let missing =
get_missing_required_aria_properties(&element);
assert!(missing.is_none());
}
#[test]
fn test_partial_slider_properties() {
let html = r#"<div role="slider" aria-valuenow="50">Test</div>"#;
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("div").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let missing =
get_missing_required_aria_properties(&element)
.unwrap();
assert!(!missing.contains(&"aria-valuenow".to_string()));
assert!(missing.contains(&"aria-valuemin".to_string()));
assert!(missing.contains(&"aria-valuemax".to_string()));
}
#[test]
fn test_unknown_role() {
let html = r#"<div role="unknown">Test</div>"#;
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("div").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let missing =
get_missing_required_aria_properties(&element);
assert!(missing.is_none());
}
#[test]
fn test_no_role() {
let html = "<div>Test</div>";
let fragment = Html::parse_fragment(html);
let selector = Selector::parse("div").unwrap();
let element =
fragment.select(&selector).next().unwrap();
let missing =
get_missing_required_aria_properties(&element);
assert!(missing.is_none());
}
}
}
#[cfg(test)]
mod accessibility_tests {
use crate::accessibility::{
get_missing_required_aria_properties, is_valid_aria_role,
is_valid_language_code,
};
use scraper::Selector;
#[test]
fn test_is_valid_language_code() {
assert!(
is_valid_language_code("en"),
"Valid language code 'en' was incorrectly rejected"
);
assert!(
is_valid_language_code("en-US"),
"Valid language code 'en-US' was incorrectly rejected"
);
assert!(
!is_valid_language_code("123"),
"Invalid language code '123' was incorrectly accepted"
);
assert!(!is_valid_language_code("日本語"), "Non-ASCII language code '日本語' was incorrectly accepted");
}
#[test]
fn test_is_valid_aria_role() {
use scraper::Html;
let html = r#"<button></button>"#;
let document = Html::parse_fragment(html);
let element = document
.select(&Selector::parse("button").unwrap())
.next()
.unwrap();
assert!(
is_valid_aria_role("button", &element),
"Valid ARIA role 'button' was incorrectly rejected"
);
assert!(
!is_valid_aria_role("invalid-role", &element),
"Invalid ARIA role 'invalid-role' was incorrectly accepted"
);
}
#[test]
fn test_get_missing_required_aria_properties() {
use scraper::{Html, Selector};
let html = r#"<div role="slider"></div>"#;
let document = Html::parse_fragment(html);
let element = document
.select(&Selector::parse("div").unwrap())
.next()
.unwrap();
let missing_props =
get_missing_required_aria_properties(&element).unwrap();
assert!(
missing_props.contains(&"aria-valuenow".to_string()),
"Did not detect missing 'aria-valuenow' for role 'slider'"
);
assert!(
missing_props.contains(&"aria-valuemin".to_string()),
"Did not detect missing 'aria-valuemin' for role 'slider'"
);
assert!(
missing_props.contains(&"aria-valuemax".to_string()),
"Did not detect missing 'aria-valuemax' for role 'slider'"
);
let html = r#"<div role="slider" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"></div>"#;
let document = Html::parse_fragment(html);
let element = document
.select(&Selector::parse("div").unwrap())
.next()
.unwrap();
let missing_props =
get_missing_required_aria_properties(&element);
assert!(missing_props.is_none(), "Unexpectedly found missing properties for a complete slider");
let html =
r#"<div role="slider" aria-valuenow="50"></div>"#;
let document = Html::parse_fragment(html);
let element = document
.select(&Selector::parse("div").unwrap())
.next()
.unwrap();
let missing_props =
get_missing_required_aria_properties(&element).unwrap();
assert!(
!missing_props.contains(&"aria-valuenow".to_string()),
"Incorrectly flagged 'aria-valuenow' as missing"
);
assert!(
missing_props.contains(&"aria-valuemin".to_string()),
"Did not detect missing 'aria-valuemin' for role 'slider'"
);
assert!(
missing_props.contains(&"aria-valuemax".to_string()),
"Did not detect missing 'aria-valuemax' for role 'slider'"
);
}
}
#[cfg(test)]
mod additional_tests {
use super::*;
use scraper::Html;
#[test]
fn test_validate_empty_html() {
let html = "";
let config = AccessibilityConfig::default();
let report = validate_wcag(html, &config, None).unwrap();
assert_eq!(
report.issue_count, 0,
"Empty HTML should not produce issues"
);
}
#[test]
fn test_validate_only_whitespace_html() {
let html = " ";
let config = AccessibilityConfig::default();
let report = validate_wcag(html, &config, None).unwrap();
assert_eq!(
report.issue_count, 0,
"Whitespace-only HTML should not produce issues"
);
}
#[test]
fn test_validate_language_with_edge_cases() {
let html = "<html lang=\"en-US\"></html>";
let _config = AccessibilityConfig::default();
let mut issues = Vec::new();
let document = Html::parse_document(html);
check_language_attributes(&document, &mut issues).unwrap();
assert_eq!(
issues.len(),
0,
"Valid language declaration should not create issues"
);
}
#[test]
fn test_validate_invalid_language_code() {
let html = "<html lang=\"invalid-lang\"></html>";
let _config = AccessibilityConfig::default();
let mut issues = Vec::new();
let document = Html::parse_document(html);
check_language_attributes(&document, &mut issues).unwrap();
assert!(
issues
.iter()
.any(|i| i.issue_type
== IssueType::LanguageDeclaration),
"Failed to detect invalid language declaration"
);
}
#[test]
fn test_edge_case_for_generate_unique_id() {
let ids: Vec<String> =
(0..100).map(|_| generate_unique_id()).collect();
let unique_ids: HashSet<String> = ids.into_iter().collect();
assert_eq!(
unique_ids.len(),
100,
"Generated IDs are not unique in edge case testing"
);
}
#[test]
fn test_enhance_landmarks_noop() {
let html = "<div>Simple Content</div>";
let builder = HtmlBuilder::new(html);
let result = enhance_landmarks(builder);
assert!(
result.is_ok(),
"Failed to handle simple HTML content"
);
assert_eq!(result.unwrap().build(), html, "Landmark enhancement altered simple content unexpectedly");
}
#[test]
fn test_html_with_non_standard_elements() {
let html =
"<custom-element aria-label=\"test\"></custom-element>";
let cleaned_html = remove_invalid_aria_attributes(html);
assert_eq!(cleaned_html, html, "Unexpectedly modified valid custom element with ARIA attributes");
}
#[test]
fn test_add_aria_to_buttons() {
let html = r#"<button>Click me</button>"#;
let builder = HtmlBuilder::new(html);
let result = add_aria_to_buttons(builder).unwrap().build();
assert!(result.contains("aria-label"));
}
#[test]
fn test_add_aria_to_empty_buttons() {
let html = r#"<button></button>"#;
let builder = HtmlBuilder::new(html);
let result = add_aria_to_buttons(builder).unwrap();
assert!(result.build().contains("aria-label"));
}
#[test]
fn test_validate_wcag_empty_html() {
let html = "";
let config = AccessibilityConfig::default();
let disable_checks = None;
let result = validate_wcag(html, &config, disable_checks);
match result {
Ok(report) => assert!(
report.issues.is_empty(),
"Empty HTML should have no issues"
),
Err(e) => {
panic!("Validation failed with error: {:?}", e)
}
}
}
#[test]
fn test_validate_wcag_with_complex_html() {
let html = "
<html>
<head></head>
<body>
<button>Click me</button>
<a href=\"\\#\"></a>
</body>
</html>
";
let config = AccessibilityConfig::default();
let disable_checks = None;
let result = validate_wcag(html, &config, disable_checks);
match result {
Ok(report) => assert!(
!report.issues.is_empty(),
"Report should have issues"
),
Err(e) => {
panic!("Validation failed with error: {:?}", e)
}
}
}
#[test]
fn test_generate_unique_id_uniqueness() {
let id1 = generate_unique_id();
let id2 = generate_unique_id();
assert_ne!(id1, id2);
}
#[test]
fn test_try_create_selector_valid() {
let selector = "div.class";
let result = try_create_selector(selector);
assert!(result.is_some());
}
#[test]
fn test_try_create_selector_invalid() {
let selector = "div..class";
let result = try_create_selector(selector);
assert!(result.is_none());
}
#[test]
fn test_try_create_regex_valid() {
let pattern = r"\d+";
let result = try_create_regex(pattern);
assert!(result.is_some());
}
#[test]
fn test_try_create_regex_invalid() {
let pattern = r"\d+(";
let result = try_create_regex(pattern);
assert!(result.is_none());
}
#[test]
fn test_enhance_descriptions() {
let builder =
HtmlBuilder::new("<html><body></body></html>");
let result = enhance_descriptions(builder);
assert!(result.is_ok(), "Enhance descriptions failed");
}
#[test]
fn test_error_from_try_from_int_error() {
let result: std::result::Result<u8, _> = i32::try_into(300); let err = result.unwrap_err(); let error: Error = Error::from(err);
if let Error::HtmlProcessingError { message, source } =
error
{
assert_eq!(message, "Integer conversion error");
assert!(source.is_some());
} else {
panic!("Expected HtmlProcessingError");
}
}
#[test]
fn test_wcag_level_display() {
assert_eq!(WcagLevel::A.to_string(), "A");
assert_eq!(WcagLevel::AA.to_string(), "AA");
assert_eq!(WcagLevel::AAA.to_string(), "AAA");
}
#[test]
fn test_check_keyboard_navigation() {
let document =
Html::parse_document("<a tabindex='-1'></a>");
let mut issues = vec![];
let result = AccessibilityReport::check_keyboard_navigation(
&document,
&mut issues,
);
assert!(result.is_ok());
assert_eq!(issues.len(), 1);
assert_eq!(
issues[0].message,
"Negative tabindex prevents keyboard focus"
);
}
#[test]
fn test_check_language_attributes() {
let document = Html::parse_document("<html></html>");
let mut issues = vec![];
let result = AccessibilityReport::check_language_attributes(
&document,
&mut issues,
);
assert!(result.is_ok());
assert_eq!(issues.len(), 1);
assert_eq!(
issues[0].message,
"Missing language declaration"
);
}
}
mod missing_tests {
use super::*;
use std::collections::HashSet;
#[test]
fn test_color_contrast_ratio() {
let low_contrast = 2.5;
let high_contrast = 7.1;
let config = AccessibilityConfig {
min_contrast_ratio: 4.5,
..Default::default()
};
assert!(
low_contrast < config.min_contrast_ratio,
"Low contrast should not pass"
);
assert!(
high_contrast >= config.min_contrast_ratio,
"High contrast should pass"
);
}
#[test]
fn test_dynamic_content_aria_attributes() {
let html = r#"<div aria-live="polite"></div>"#;
let cleaned_html = remove_invalid_aria_attributes(html);
assert_eq!(
cleaned_html, html,
"Dynamic content ARIA attributes should be preserved"
);
}
#[test]
fn test_strict_wcag_aaa_behavior() {
let html = r#"<h1>Main Title</h1><h4>Skipped Level</h4>"#;
let config = AccessibilityConfig {
wcag_level: WcagLevel::AAA,
..Default::default()
};
let report = validate_wcag(html, &config, None).unwrap();
assert!(
report.issue_count > 0,
"WCAG AAA strictness should detect issues"
);
let issue = &report.issues[0];
assert_eq!(
issue.issue_type,
IssueType::LanguageDeclaration,
"Expected heading structure issue"
);
}
#[test]
fn test_large_html_performance() {
let large_html =
"<div>".repeat(1_000) + &"</div>".repeat(1_000);
let result = validate_wcag(
&large_html,
&AccessibilityConfig::default(),
None,
);
assert!(
result.is_ok(),
"Large HTML should not cause performance issues"
);
}
#[test]
fn test_nested_elements_with_aria_attributes() {
let html = r#"
<div>
<button aria-label="Test">Click</button>
<nav aria-label="Main Navigation">
<ul><li>Item 1</li></ul>
</nav>
</div>
"#;
let enhanced_html =
add_aria_attributes(html, None).unwrap();
assert!(
enhanced_html.contains("aria-label"),
"Nested elements should have ARIA attributes"
);
}
#[test]
fn test_deeply_nested_headings() {
let html = r#"
<div>
<h1>Main Title</h1>
<div>
<h3>Skipped Level</h3>
</div>
</div>
"#;
let mut issues = Vec::new();
let document = Html::parse_document(html);
check_heading_structure(&document, &mut issues);
assert!(
issues.iter().any(|issue| issue.issue_type == IssueType::HeadingStructure),
"Deeply nested headings with skipped levels should produce issues"
);
}
#[test]
fn test_unique_id_long_runtime() {
let ids: HashSet<_> =
(0..10_000).map(|_| generate_unique_id()).collect();
assert_eq!(
ids.len(),
10_000,
"Generated IDs should be unique over long runtime"
);
}
#[test]
fn test_custom_selector_failure() {
let invalid_selector = "div..class";
let result = try_create_selector(invalid_selector);
assert!(
result.is_none(),
"Invalid selector should return None"
);
}
#[test]
fn test_invalid_regex_pattern() {
let invalid_pattern = r"\d+(";
let result = try_create_regex(invalid_pattern);
assert!(
result.is_none(),
"Invalid regex pattern should return None"
);
}
#[test]
fn test_invalid_aria_attribute_removal() {
let html = r#"<div aria-hidden="invalid"></div>"#;
let cleaned_html = remove_invalid_aria_attributes(html);
assert!(
!cleaned_html.contains("aria-hidden"),
"Invalid ARIA attributes should be removed"
);
}
#[test]
fn test_invalid_selector() {
let invalid_selector = "div..class";
let result = try_create_selector(invalid_selector);
assert!(result.is_none());
}
#[test]
fn test_issue_type_in_issue_struct() {
let issue = Issue {
issue_type: IssueType::MissingAltText,
message: "Alt text is missing".to_string(),
guideline: Some("WCAG 1.1.1".to_string()),
element: Some("<img>".to_string()),
suggestion: Some(
"Add descriptive alt text".to_string(),
),
};
assert_eq!(issue.issue_type, IssueType::MissingAltText);
}
#[test]
fn test_add_aria_to_navs() {
let html = "<nav>Main Navigation</nav>";
let builder = HtmlBuilder::new(html);
let result = add_aria_to_navs(builder).unwrap().build();
assert!(result.contains(r#"aria-label="navigation""#));
assert!(result.contains(r#"role="navigation""#));
}
#[test]
fn test_add_aria_to_forms() {
let html = "<form>Form Content</form>";
let builder = HtmlBuilder::new(html);
let result = add_aria_to_forms(builder).unwrap().build();
assert!(result.contains(r#"aria-labelledby="form-"#));
assert!(result.contains(r#"role="form""#));
}
#[test]
fn test_add_aria_to_inputs() {
let html = r#"<input type="text">"#;
let builder = HtmlBuilder::new(html);
let result = add_aria_to_inputs(builder).unwrap().build();
assert!(result.contains(r#"aria-label="text""#));
assert!(result.contains(r#"role="textbox""#));
}
#[test]
fn test_check_keyboard_navigation_click_handlers() {
let html = r#"<button onclick="handleClick()"></button>"#;
let document = Html::parse_document(html);
let mut issues = vec![];
AccessibilityReport::check_keyboard_navigation(
&document,
&mut issues,
)
.unwrap();
assert!(
issues.iter().any(|i| i.message == "Click handler without keyboard equivalent"),
"Expected an issue for missing keyboard equivalents, but found: {:?}",
issues
);
}
#[test]
fn test_invalid_language_code() {
let html = r#"<html lang="invalid-lang"></html>"#;
let document = Html::parse_document(html);
let mut issues = vec![];
AccessibilityReport::check_language_attributes(
&document,
&mut issues,
)
.unwrap();
assert!(issues
.iter()
.any(|i| i.message.contains("Invalid language code")));
}
#[test]
fn test_missing_required_aria_properties() {
let html = r#"<div role="slider"></div>"#;
let fragment = Html::parse_fragment(html);
let element = fragment
.select(&Selector::parse("div").unwrap())
.next()
.unwrap();
let missing =
get_missing_required_aria_properties(&element).unwrap();
assert!(missing.contains(&"aria-valuenow".to_string()));
}
}
}