pub mod analyzer;
pub mod announcer;
pub mod bfsg;
pub mod navigator;
pub mod types;
pub use a11y_perception::{linearize, linearize_with_ignored};
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 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,
excluded_node_ids: Default::default(),
}
}
impl SrAuditReport {
pub fn apply_exclusion(&mut self, is_excluded: impl Fn(&str) -> bool) -> usize {
let excluded: std::collections::BTreeSet<String> = self
.issues
.iter()
.flat_map(|issue| &issue.affected_node_ids)
.filter(|id| is_excluded(id))
.cloned()
.collect();
if excluded.is_empty() {
return 0;
}
let dropped = drop_excluded_nodes(&mut self.issues, &excluded);
self.bfsg_compliance = bfsg_compliance(&self.issues);
self.summary.bfsg_violations = self.bfsg_compliance.violations.len();
self.excluded_node_ids = excluded;
dropped
}
}
pub fn drop_excluded_nodes(
issues: &mut Vec<SrAuditIssue>,
excluded: &std::collections::BTreeSet<String>,
) -> usize {
if excluded.is_empty() {
return 0;
}
let before = issues.len();
issues.retain_mut(|issue| {
if issue.affected_node_ids.is_empty() {
return true;
}
issue.affected_node_ids.retain(|id| !excluded.contains(id));
!issue.affected_node_ids.is_empty()
});
before - issues.len()
}
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);
}
fn high(wcag: &str, nodes: &[&str]) -> SrAuditIssue {
SrAuditIssue {
wcag_criterion: Some(wcag.to_string()),
severity: "high".into(),
affected_node_ids: nodes.iter().map(|n| n.to_string()).collect(),
message: "m".into(),
}
}
fn report_with(issues: Vec<SrAuditIssue>) -> SrAuditReport {
let mut report = build_sr_audit_report(
"https://x.test/",
chrono::Utc::now(),
&AXTree::new(),
"en",
None,
);
report.bfsg_compliance = bfsg_compliance(&issues);
report.summary.bfsg_violations = report.bfsg_compliance.violations.len();
report.issues = issues;
report
}
#[test]
fn excluded_specimen_fields_no_longer_fail_the_bfsg_verdict() {
let mut report = report_with(vec![high("3.3.2", &["7", "9"])]);
assert_eq!(report.bfsg_compliance.verdict, BfsgVerdict::NonCompliant);
let dropped = report.apply_exclusion(|id| id == "7" || id == "9");
assert_eq!(dropped, 1);
assert!(report.issues.is_empty());
assert_eq!(report.bfsg_compliance.verdict, BfsgVerdict::Compliant);
assert_eq!(report.summary.bfsg_violations, 0);
assert!(report
.bfsg_compliance
.passed_criteria
.contains(&"3.3.2".to_string()));
assert_eq!(report.excluded_node_ids.len(), 2);
}
#[test]
fn an_issue_with_nodes_outside_the_exclusion_keeps_only_those() {
let mut report = report_with(vec![high("3.3.2", &["7", "12"])]);
let dropped = report.apply_exclusion(|id| id == "7");
assert_eq!(dropped, 0);
assert_eq!(report.issues[0].affected_node_ids, vec!["12".to_string()]);
assert_eq!(report.bfsg_compliance.verdict, BfsgVerdict::NonCompliant);
assert_eq!(
report.bfsg_compliance.violations[0].affected_node_ids,
vec!["12".to_string()]
);
}
#[test]
fn page_level_issues_are_never_excluded() {
let mut issues = vec![high("2.4.1", &[]), high("4.1.2", &["3"])];
let excluded = ["3".to_string()].into_iter().collect();
assert_eq!(drop_excluded_nodes(&mut issues, &excluded), 1);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].wcag_criterion.as_deref(), Some("2.4.1"));
}
}