use crate::accessibility::{AXNode, AXTree};
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};
pub const INFO_RELATIONSHIPS_RULE: RuleMetadata = RuleMetadata {
id: "1.3.1",
name: "Info and Relationships",
level: WcagLevel::A,
severity: Severity::High,
description: "Information, structure, and relationships can be programmatically determined",
help_url: "https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html",
axe_id: "definition-list",
tags: &["wcag2a", "wcag131", "cat.structure"],
};
pub fn check_info_relationships(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
for node in tree.iter() {
if node.ignored {
continue;
}
results.nodes_checked += 1;
let role = node.role.as_deref().unwrap_or("").to_lowercase();
if role == "table" {
check_table_structure(node, tree, &mut results);
}
if role == "list" {
check_list_structure(node, tree, &mut results);
}
if is_form_control(&role) {
check_form_grouping(node, tree, &mut results);
}
if role == "cell" || role == "gridcell" {
check_cell_headers(node, tree, &mut results);
}
}
results
}
fn check_table_structure(node: &AXNode, tree: &AXTree, results: &mut WcagResults) {
let mut has_headers = false;
let mut has_data_cells = false;
for child_id in &node.child_ids {
if let Some(child) = tree.get_node(child_id) {
let child_role = child.role.as_deref().unwrap_or("").to_lowercase();
if child_role == "rowgroup" || child_role == "row" {
for grandchild_id in &child.child_ids {
if let Some(grandchild) = tree.get_node(grandchild_id) {
let gc_role = grandchild.role.as_deref().unwrap_or("").to_lowercase();
if gc_role == "columnheader" || gc_role == "rowheader" {
has_headers = true;
}
if gc_role == "cell" || gc_role == "gridcell" {
has_data_cells = true;
}
}
}
}
if child_role == "columnheader" || child_role == "rowheader" {
has_headers = true;
}
if child_role == "cell" || child_role == "gridcell" {
has_data_cells = true;
}
}
}
if has_data_cells && !has_headers {
let violation = Violation::new(
INFO_RELATIONSHIPS_RULE.id,
INFO_RELATIONSHIPS_RULE.name,
INFO_RELATIONSHIPS_RULE.level,
Severity::High,
"Data table lacks header cells",
&node.node_id,
)
.with_role(node.role.clone())
.with_fix("Add <th> elements for column and/or row headers in data tables")
.with_help_url(INFO_RELATIONSHIPS_RULE.help_url);
results.add_violation(violation);
} else if has_headers {
results.passes += 1;
}
}
fn check_list_structure(node: &AXNode, tree: &AXTree, results: &mut WcagResults) {
let mut has_list_items = false;
let mut has_non_list_items = false;
for child_id in &node.child_ids {
if let Some(child) = tree.get_node(child_id) {
let child_role = child.role.as_deref().unwrap_or("").to_lowercase();
if child_role == "listitem" {
has_list_items = true;
} else if !child_role.is_empty() && child_role != "presentation" && child_role != "none"
{
has_non_list_items = true;
}
}
}
if has_non_list_items && !has_list_items {
let violation = Violation::new(
INFO_RELATIONSHIPS_RULE.id,
INFO_RELATIONSHIPS_RULE.name,
INFO_RELATIONSHIPS_RULE.level,
Severity::Medium,
"List does not contain proper list item elements",
&node.node_id,
)
.with_role(node.role.clone())
.with_fix("Use <li> elements as direct children of <ul> or <ol> lists")
.with_help_url(INFO_RELATIONSHIPS_RULE.help_url);
results.add_violation(violation);
} else if has_list_items {
results.passes += 1;
}
}
fn check_form_grouping(node: &AXNode, tree: &AXTree, results: &mut WcagResults) {
let role = node.role.as_deref().unwrap_or("").to_lowercase();
if role == "radio" {
if let Some(ref parent_id) = node.parent_id {
if let Some(parent) = tree.get_node(parent_id) {
let parent_role = parent.role.as_deref().unwrap_or("").to_lowercase();
if parent_role != "radiogroup" && parent_role != "group" {
let violation = Violation::new(
INFO_RELATIONSHIPS_RULE.id,
INFO_RELATIONSHIPS_RULE.name,
INFO_RELATIONSHIPS_RULE.level,
Severity::Medium,
"Radio button is not contained in a group",
&node.node_id,
)
.with_role(node.role.clone())
.with_name(node.name.clone())
.with_fix("Group related radio buttons using <fieldset> and <legend> or role=\"radiogroup\"")
.with_help_url(INFO_RELATIONSHIPS_RULE.help_url);
results.add_violation(violation);
return;
}
}
}
}
results.passes += 1;
}
fn check_cell_headers(node: &AXNode, _tree: &AXTree, results: &mut WcagResults) {
let has_content = node
.name
.as_ref()
.map(|n| !n.trim().is_empty())
.unwrap_or(false);
if has_content {
results.passes += 1;
}
}
fn is_form_control(role: &str) -> bool {
matches!(
role,
"textbox"
| "searchbox"
| "combobox"
| "listbox"
| "spinbutton"
| "slider"
| "checkbox"
| "radio"
| "switch"
| "button"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn create_node(id: &str, role: &str, name: Option<&str>, children: Vec<&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: children.iter().map(|s| s.to_string()).collect(),
parent_id: None,
backend_dom_node_id: None,
}
}
#[test]
fn test_info_relationships_rule_metadata() {
assert_eq!(INFO_RELATIONSHIPS_RULE.id, "1.3.1");
assert_eq!(INFO_RELATIONSHIPS_RULE.level, WcagLevel::A);
}
#[test]
fn test_table_with_headers() {
let table = create_node("1", "table", None, vec!["2", "3"]);
let header = create_node("2", "columnheader", Some("Name"), vec![]);
let cell = create_node("3", "cell", Some("John"), vec![]);
let tree = AXTree::from_nodes(vec![table, header, cell]);
let results = check_info_relationships(&tree);
assert!(results.violations.is_empty() || results.passes > 0);
}
#[test]
fn test_table_without_headers() {
let table = create_node("1", "table", None, vec!["2"]);
let cell = create_node("2", "cell", Some("Data"), vec![]);
let tree = AXTree::from_nodes(vec![table, cell]);
let results = check_info_relationships(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("header")));
}
#[test]
fn test_is_form_control() {
assert!(is_form_control("textbox"));
assert!(is_form_control("checkbox"));
assert!(is_form_control("radio"));
assert!(!is_form_control("link"));
assert!(!is_form_control("heading"));
}
}