pub mod analyzer;
pub mod announcer;
pub mod bfsg;
pub mod linearizer;
pub mod navigator;
pub mod types;
pub use analyzer::{analyze_reading_sequence, name_quality_score};
pub use announcer::{announce, announce_localized};
pub use bfsg::{map_to_bfsg, wcag_21_aa_criteria, BfsgMapping};
pub use linearizer::{linearize, linearize_with_ignored};
pub use navigator::{navigation_views, NavigationViews};
pub use types::{
AnnouncedReadingItem, BfsgCompliance, BfsgVerdict, BfsgViolation, IgnoredReadingNode,
QualityScoreContext, ReadingItem, ScreenReaderSummary, SrAuditIssue, SrAuditQuality,
SrAuditReport, SrAuditSummary,
};
use crate::accessibility::AXTree;
use crate::i18n::I18n;
pub fn build_sr_audit_report(
url: &str,
timestamp: chrono::DateTime<chrono::Utc>,
tree: &AXTree,
detect_locale: &str,
pattern_analysis: Option<&crate::patterns::PatternAnalysis>,
) -> SrAuditReport {
let i18n = I18n::new("en").expect("english locale parses");
let reading_items = linearize(tree);
let navigation_views = navigation_views(&reading_items);
let has_disclosure_menu_pattern =
pattern_analysis.is_some_and(|patterns| patterns.has_recognized("DisclosureMenu"));
let issues = analyze_reading_sequence(
&reading_items,
&navigation_views,
detect_locale,
true,
has_disclosure_menu_pattern,
);
let bfsg_compliance = bfsg_compliance(&issues);
let reading_sequence = reading_items
.iter()
.cloned()
.map(|item| AnnouncedReadingItem {
announcement: announce_localized(&item, &i18n),
item,
})
.collect::<Vec<_>>();
SrAuditReport {
schema_version: "1.0",
report_type: "screen_reader_audit",
url: url.to_string(),
timestamp,
tool_version: env!("CARGO_PKG_VERSION"),
summary: SrAuditSummary {
total_announced_nodes: reading_items.len(),
tab_stops: reading_items.iter().filter(|item| item.tab_stop).count(),
bfsg_violations: bfsg_compliance.violations.len(),
name_quality_score: name_quality_score(&reading_items, detect_locale),
landmark_quality_score: landmark_quality_score(&navigation_views),
heading_quality_score: heading_quality_score(&navigation_views),
quality_score_context: QualityScoreContext::default(),
audit_quality: detect_audit_quality(reading_items.len(), &navigation_views),
},
reading_sequence,
navigation_views,
issues,
bfsg_compliance,
}
}
fn detect_audit_quality(total_nodes: usize, views: &NavigationViews) -> SrAuditQuality {
const CONSENT_WALL_NODE_THRESHOLD: usize = 145;
let has_structural_landmark = views.landmarks.iter().any(|l| {
matches!(
l.role.as_str(),
"banner" | "navigation" | "main" | "contentinfo"
) && l.quality != navigator::LandmarkQuality::MissingMain
});
if total_nodes < CONSENT_WALL_NODE_THRESHOLD && !has_structural_landmark {
SrAuditQuality::ConsentWallSuspected
} else {
SrAuditQuality::Ok
}
}
fn landmark_quality_score(views: &NavigationViews) -> u32 {
const REQUIRED: &[&str] = &["banner", "navigation", "contentinfo", "main"];
let present_count = REQUIRED
.iter()
.filter(|&&role| {
views
.landmarks
.iter()
.any(|l| l.role == role && l.quality != navigator::LandmarkQuality::MissingMain)
})
.count();
percentage(present_count, REQUIRED.len())
}
fn heading_quality_score(views: &NavigationViews) -> u32 {
percentage(
views
.headings
.iter()
.filter(|item| item.quality == navigator::HeadingQuality::Good)
.count(),
views.headings.len(),
)
}
fn percentage(passed: usize, total: usize) -> u32 {
if total == 0 {
100
} else {
((passed as f64 / total as f64) * 100.0).round() as u32
}
}
const EVALUATED_WCAG_CRITERIA: &[&str] = &["1.3.1", "2.4.1", "2.4.4", "2.4.6", "3.3.2", "4.1.2"];
fn bfsg_compliance(issues: &[SrAuditIssue]) -> BfsgCompliance {
let violations = issues
.iter()
.filter_map(|issue| {
let wcag = issue.wcag_criterion.as_deref()?;
let mapping = map_to_bfsg(wcag)?;
mapping.fix_required.then(|| BfsgViolation {
wcag_criterion: wcag.to_string(),
en_301_549_clause: Some(mapping.en_301549_clause.to_string()),
bfsg_reference: Some(mapping.bfsg_paragraph.to_string()),
fix_required: mapping.fix_required,
affected_node_ids: issue.affected_node_ids.clone(),
})
})
.collect::<Vec<_>>();
let failed: std::collections::HashSet<_> = violations
.iter()
.map(|violation| violation.wcag_criterion.as_str())
.collect();
let passed_criteria = EVALUATED_WCAG_CRITERIA
.iter()
.filter(|criterion| !failed.contains(*criterion))
.map(|criterion| criterion.to_string())
.collect();
BfsgCompliance {
verdict: if violations.is_empty() {
BfsgVerdict::Compliant
} else {
BfsgVerdict::NonCompliant
},
violations,
passed_criteria,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::screen_reader::navigator::navigation_views;
use crate::screen_reader::ReadingItem;
fn item(seq: usize, role: &str) -> ReadingItem {
ReadingItem {
seq,
role: Some(role.to_string()),
name: Some(role.to_string()),
description: None,
value: None,
states: vec![],
tab_stop: false,
depth: 0,
node_id: format!("node-{seq}"),
}
}
fn issue(wcag_criterion: &str) -> SrAuditIssue {
SrAuditIssue {
wcag_criterion: Some(wcag_criterion.to_string()),
severity: "low".into(),
affected_node_ids: vec![],
message: "test issue".into(),
}
}
#[test]
fn bfsg_compliance_does_not_claim_unevaluated_criteria_as_passed() {
let issues = vec![issue("1.3.6"), issue("1.3.6"), issue("1.3.6")];
let compliance = bfsg_compliance(&issues);
assert_eq!(compliance.verdict, BfsgVerdict::Compliant);
assert!(compliance.violations.is_empty());
assert_eq!(
compliance.passed_criteria.len(),
EVALUATED_WCAG_CRITERIA.len()
);
for criterion in EVALUATED_WCAG_CRITERIA {
assert!(
compliance.passed_criteria.contains(&criterion.to_string()),
"expected {criterion} in passed_criteria"
);
}
assert!(!compliance.passed_criteria.contains(&"1.4.3".to_string()));
assert!(!compliance.passed_criteria.contains(&"2.1.1".to_string()));
}
#[test]
fn bfsg_compliance_excludes_a_failed_criterion_from_passed_criteria() {
let issues = vec![issue("2.4.4")];
let compliance = bfsg_compliance(&issues);
assert_eq!(compliance.verdict, BfsgVerdict::NonCompliant);
assert_eq!(compliance.violations.len(), 1);
assert!(!compliance.passed_criteria.contains(&"2.4.4".to_string()));
assert_eq!(
compliance.passed_criteria.len(),
EVALUATED_WCAG_CRITERIA.len() - 1
);
}
#[test]
fn landmark_quality_score_only_main_is_low() {
let views = navigation_views(&[item(0, "main")]);
assert_eq!(landmark_quality_score(&views), 25);
}
#[test]
fn landmark_quality_score_all_required_is_100() {
let views = navigation_views(&[
item(0, "banner"),
item(1, "navigation"),
item(2, "main"),
item(3, "contentinfo"),
]);
assert_eq!(landmark_quality_score(&views), 100);
}
#[test]
fn small_page_with_structural_landmarks_is_not_flagged_as_consent_wall() {
let items = vec![
item(0, "banner"),
item(1, "navigation"),
item(2, "main"),
item(3, "contentinfo"),
];
let views = navigation_views(&items);
assert_eq!(detect_audit_quality(10, &views), SrAuditQuality::Ok);
}
#[test]
fn small_page_without_landmarks_is_flagged_as_consent_wall() {
let views = navigation_views(&[item(0, "generic")]);
assert_eq!(
detect_audit_quality(10, &views),
SrAuditQuality::ConsentWallSuspected
);
}
#[test]
fn large_page_without_landmarks_is_not_flagged_as_consent_wall() {
let views = navigation_views(&[item(0, "generic")]);
assert_eq!(detect_audit_quality(200, &views), SrAuditQuality::Ok);
}
}