use crate::accessibility::AXTree;
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};
pub(super) const BYPASS_BLOCKS_RULE: RuleMetadata = RuleMetadata {
id: "2.4.1",
name: "Bypass Blocks",
level: WcagLevel::A,
severity: Severity::Medium,
description: "A mechanism is available to bypass blocks of content that are repeated",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html",
axe_id: "bypass",
tags: &["wcag2a", "wcag241", "cat.keyboard"],
};
pub fn check_bypass_blocks(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
results.nodes_checked = tree.len();
let heading_count = count_headings(tree);
if heading_count == 0 && dialog_open(tree) {
results.add_violation(
Violation::new(
BYPASS_BLOCKS_RULE.id,
BYPASS_BLOCKS_RULE.name,
BYPASS_BLOCKS_RULE.level,
Severity::Low,
"No headings reachable while a dialog is open; check the page with the dialog closed",
"page",
)
.with_help_url(BYPASS_BLOCKS_RULE.help_url)
.with_rule_id(BYPASS_BLOCKS_RULE.axe_id)
.as_warning(),
);
} else if heading_count == 0 {
let violation = Violation::new(
BYPASS_BLOCKS_RULE.id,
BYPASS_BLOCKS_RULE.name,
BYPASS_BLOCKS_RULE.level,
BYPASS_BLOCKS_RULE.severity,
"No headings found for content navigation",
"page",
)
.with_fix("Add headings (h1-h6) to structure your content")
.with_help_url(BYPASS_BLOCKS_RULE.help_url)
.with_rule_id(BYPASS_BLOCKS_RULE.axe_id);
results.add_violation(violation);
} else {
results.passes += 1;
}
results
}
fn dialog_open(tree: &AXTree) -> bool {
tree.iter()
.any(|n| !n.ignored && matches!(n.role.as_deref(), Some("dialog" | "alertdialog")))
}
fn count_headings(tree: &AXTree) -> usize {
tree.iter()
.filter(|node| node.role.as_deref() == Some("heading"))
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::AXNode;
fn create_node(id: &str, role: &str, name: Option<&str>) -> AXNode {
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: name.map(String::from),
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
}
}
#[test]
fn test_bypass_blocks_rule_metadata() {
assert_eq!(BYPASS_BLOCKS_RULE.id, "2.4.1");
assert_eq!(BYPASS_BLOCKS_RULE.level, WcagLevel::A);
}
#[test]
fn test_page_with_headings_passes() {
let tree = AXTree::from_nodes(vec![
create_node("1", "main", None),
create_node("2", "heading", Some("Page Title")),
]);
let results = check_bypass_blocks(&tree);
assert!(results.violations.is_empty(), "{:?}", results.violations);
}
#[test]
fn test_page_without_headings_flagged() {
let tree = AXTree::from_nodes(vec![
create_node("1", "generic", None),
create_node("2", "paragraph", Some("Some text")),
]);
let results = check_bypass_blocks(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("No headings found")));
}
#[test]
fn behind_open_dialog_missing_headings_are_a_note() {
let tree = AXTree::from_nodes(vec![
create_node("root", "RootWebArea", Some("Page")),
create_node("d", "dialog", Some("Cookies")),
create_node("b", "button", Some("Accept")),
]);
let r = check_bypass_blocks(&tree);
assert_eq!(r.violations.len(), 0);
assert_eq!(r.warnings.len(), 1);
}
#[test]
fn test_missing_skip_link_and_main_not_reported_here() {
let tree = AXTree::from_nodes(vec![
create_node("1", "link", Some("Home")),
create_node("2", "heading", Some("Page Title")),
]);
let results = check_bypass_blocks(&tree);
assert!(results.violations.is_empty(), "{:?}", results.violations);
}
}