use crate::accessibility::{AXNode, AXTree};
use crate::cli::WcagLevel;
use crate::wcag::types::{FindingKind, RuleMetadata, Severity, Violation, WcagResults};
pub const RULE_META_MEDIA: RuleMetadata = RuleMetadata {
id: "1.2.1",
name: "Audio-only and Video-only",
level: WcagLevel::A,
severity: Severity::Medium,
description: "Video and audio elements must have accessible alternatives",
help_url:
"https://www.w3.org/WAI/WCAG21/Understanding/audio-only-and-video-only-prerecorded.html",
axe_id: "video-caption",
tags: &["wcag2a", "wcag121", "cat.media"],
};
pub const RULE_META_CAPTIONS: RuleMetadata = RuleMetadata {
id: "1.2.2",
name: "Captions (Prerecorded)",
level: WcagLevel::A,
severity: Severity::High,
description: "Prerecorded audio content in synchronized media has captions",
help_url: "https://www.w3.org/WAI/WCAG21/Understanding/captions-prerecorded.html",
axe_id: "video-caption",
tags: &["wcag2a", "wcag122", "cat.media"],
};
pub const RULE_META_IMAGE: RuleMetadata = RuleMetadata {
id: "1.1.1",
name: "Non-text Content",
level: WcagLevel::A,
severity: Severity::High,
description: "All non-text content must have a text alternative",
help_url: "https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html",
axe_id: "image-alt",
tags: &["wcag2a", "wcag111", "cat.images"],
};
pub fn check_media_rules(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
let mut video_element_count = 0usize;
for node in tree.iter() {
if node.ignored {
continue;
}
results.nodes_checked += 1;
let role = match node.role.as_deref() {
Some(r) => r,
None => continue,
};
match role {
"application" => {
video_element_count += 1;
check_application_has_name(node, &mut results);
}
"img" => {
check_img_role_has_name(node, &mut results);
}
"presentation" | "none" => {
check_decorative_has_no_spurious_name(node, &mut results);
}
_ => {}
}
}
if video_element_count > 0 {
results.add_violation(
Violation::new(
RULE_META_CAPTIONS.id,
RULE_META_CAPTIONS.name,
RULE_META_CAPTIONS.level,
Severity::High,
format!(
"{video_element_count} media element(s) detected. \
Caption presence and accuracy cannot be verified automatically — \
review each video for correct, synchronized captions."
),
"page",
)
.with_fix(
"Ensure all prerecorded video with audio has synchronized captions. \
Use the <track kind=\"captions\"> element or a captioning service.",
)
.with_help_url(RULE_META_CAPTIONS.help_url)
.with_kind(FindingKind::NotTestable),
);
}
results
}
fn check_application_has_name(node: &AXNode, results: &mut WcagResults) {
if !node.has_name() {
let violation = Violation::new(
RULE_META_MEDIA.id,
RULE_META_MEDIA.name,
RULE_META_MEDIA.level,
Severity::Medium,
"Video element may lack accessible name or caption alternative",
&node.node_id,
)
.with_role(node.role.clone())
.with_fix(
"Add aria-label or aria-labelledby to the application/video element, and provide a transcript or captions",
)
.with_help_url(RULE_META_MEDIA.help_url);
results.add_violation(violation);
} else {
results.passes += 1;
}
}
fn check_img_role_has_name(node: &AXNode, results: &mut WcagResults) {
if !node.has_name() {
let violation = Violation::new(
RULE_META_IMAGE.id,
RULE_META_IMAGE.name,
RULE_META_IMAGE.level,
Severity::High,
"SVG image is missing an accessible name",
&node.node_id,
)
.with_role(node.role.clone())
.with_fix(
"Add a <title> element inside the SVG, or use aria-label/aria-labelledby on the SVG element",
)
.with_help_url(RULE_META_IMAGE.help_url);
results.add_violation(violation);
} else {
results.passes += 1;
}
}
fn check_decorative_has_no_spurious_name(node: &AXNode, results: &mut WcagResults) {
if node.has_name() {
let violation = Violation::new(
RULE_META_IMAGE.id,
RULE_META_IMAGE.name,
RULE_META_IMAGE.level,
Severity::Low,
"Decorative element has an accessible name (may be unnecessary)",
&node.node_id,
)
.with_role(node.role.clone())
.with_name(node.name.clone())
.with_fix(
"Remove the accessible name (alt, aria-label) from decorative elements, or change the role to convey meaningful content",
)
.with_help_url(RULE_META_IMAGE.help_url);
results.add_violation(violation);
} else {
results.passes += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXTree};
fn make_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_svg_img_without_name_flagged() {
let nodes = vec![make_node("1", "img", None)];
let tree = AXTree::from_nodes(nodes);
let results = check_media_rules(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("SVG image is missing")));
}
#[test]
fn test_svg_img_with_name_passes() {
let nodes = vec![make_node("1", "img", Some("Company logo"))];
let tree = AXTree::from_nodes(nodes);
let results = check_media_rules(&tree);
assert!(!results
.violations
.iter()
.any(|v| v.message.contains("SVG image is missing")));
}
#[test]
fn test_decorative_with_name_flagged() {
let nodes = vec![make_node("1", "presentation", Some("decorative star"))];
let tree = AXTree::from_nodes(nodes);
let results = check_media_rules(&tree);
assert!(results.violations.iter().any(|v| v
.message
.contains("Decorative element has an accessible name")));
}
#[test]
fn test_decorative_without_name_passes() {
let nodes = vec![make_node("1", "presentation", None)];
let tree = AXTree::from_nodes(nodes);
let results = check_media_rules(&tree);
assert!(!results
.violations
.iter()
.any(|v| v.message.contains("Decorative element")));
}
#[test]
fn test_application_without_name_flagged() {
let nodes = vec![make_node("1", "application", None)];
let tree = AXTree::from_nodes(nodes);
let results = check_media_rules(&tree);
assert!(results
.violations
.iter()
.any(|v| v.message.contains("Video element may lack")));
}
}