use std::sync::OnceLock;
use crate::taxonomy::rules::RULES;
pub const WCAG_AA_TOTAL: usize = 55;
pub const WCAG_VERSION: &str = "2.2";
const WCAG_22_ONLY_CRITERIA: &[&str] = &[
"2.4.11", "2.4.12", "2.4.13", "2.5.7", "2.5.8", "3.2.6", "3.3.7", "3.3.8", "3.3.9",
];
fn wcag_id_order(id: &str) -> Vec<u32> {
id.split('.').filter_map(|p| p.parse().ok()).collect()
}
const HINT_ONLY_CRITERIA: &[&str] = &[
"1.2.2", "1.3.2", "2.1.2", "2.2.2", "2.4.11", "2.5.1", "2.5.2", "2.5.4", "3.3.7",
"1.2.8", "2.2.3", "2.2.4", "2.2.5", "2.4.12", "3.1.3",
];
pub fn automated_criteria() -> &'static [(&'static str, &'static str)] {
static CACHE: OnceLock<Vec<(&'static str, &'static str)>> = OnceLock::new();
CACHE.get_or_init(|| {
let mut out: Vec<(&'static str, &'static str)> = Vec::new();
for rule in RULES {
let (Some(ext_ref), Some(level)) = (rule.external_ref, rule.external_level) else {
continue;
};
let Some(id) = ext_ref.strip_prefix("WCAG ") else {
continue;
};
if HINT_ONLY_CRITERIA.contains(&id) {
continue;
}
if !out.iter().any(|(existing, _)| *existing == id) {
out.push((id, level));
}
}
out.sort_by_key(|a| wcag_id_order(a.0));
out
})
}
const MANUAL_REVIEW_CRITERIA_RAW: &[(&str, &str, &str)] = &[
("1.2.1", "A", "Audio-only and Video-only (Prerecorded)"),
("1.2.2", "A", "Captions (Prerecorded)"),
("1.2.3", "A", "Audio Description or Media Alternative"),
("1.2.5", "AA", "Audio Description (Prerecorded)"),
("1.3.2", "A", "Meaningful Sequence"),
("1.4.2", "A", "Audio Control"),
("1.4.5", "AA", "Images of Text"),
("2.1.2", "A", "No Keyboard Trap"),
("2.1.4", "A", "Character Key Shortcuts"),
("2.2.2", "A", "Pause, Stop, Hide"),
("2.3.1", "A", "Three Flashes or Below Threshold"),
("2.4.11", "AA", "Focus Not Obscured (Minimum)"),
("2.5.1", "A", "Pointer Gestures"),
("2.5.2", "A", "Pointer Cancellation"),
("2.5.4", "A", "Motion Actuation"),
("2.5.7", "AA", "Dragging Movements"),
("3.2.3", "AA", "Consistent Navigation"),
("3.2.4", "AA", "Consistent Identification"),
("3.2.6", "A", "Consistent Help"),
("3.3.3", "AA", "Error Suggestion"),
("3.3.4", "AA", "Error Prevention (Legal, Financial, Data)"),
("3.3.7", "A", "Redundant Entry"),
];
pub fn manual_review_criteria() -> &'static [(&'static str, &'static str, &'static str)] {
static CACHE: OnceLock<Vec<(&'static str, &'static str, &'static str)>> = OnceLock::new();
CACHE.get_or_init(|| {
let automated = automated_criteria();
MANUAL_REVIEW_CRITERIA_RAW
.iter()
.filter(|(id, _, _)| !automated.iter().any(|(aid, _)| aid == id))
.copied()
.collect()
})
}
pub fn coverage_stats() -> (usize, usize) {
let aa_count = automated_criteria()
.iter()
.filter(|(_, l)| *l == "A" || *l == "AA")
.count();
(aa_count, WCAG_AA_TOTAL)
}
pub fn is_wcag22_only(id: &str) -> bool {
WCAG_22_ONLY_CRITERIA.contains(&id)
}
const WCAG_22_TITLES_DE: &[(&str, &str)] = &[
("2.4.11", "Fokus nicht verdeckt (Minimum)"),
("2.5.7", "Ziehbewegungen"),
("2.5.8", "Zielgröße (Minimum)"),
("3.2.6", "Konsistente Hilfe"),
("3.3.7", "Redundante Eingabe"),
("3.3.8", "Barrierefreie Authentifizierung (Minimum)"),
];
pub fn manual_review_criterion_name_de(id: &str, fallback_en: &'static str) -> &'static str {
crate::wcag::en301549::EN301549_WEB_CLAUSES
.iter()
.find(|c| c.wcag == id)
.map(|c| c.title_de)
.or_else(|| {
WCAG_22_TITLES_DE
.iter()
.find(|(wcag, _)| *wcag == id)
.map(|(_, title)| *title)
})
.unwrap_or(fallback_en)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_appendix_groups_partition_the_automated_criteria() {
let (scoped, _) = coverage_stats();
let aaa = automated_criteria()
.iter()
.filter(|(_, level)| *level == "AAA")
.count();
assert_eq!(
scoped + aaa,
automated_criteria().len(),
"A/AA {scoped} + AAA {aaa} must equal the {} listed",
automated_criteria().len(),
);
}
#[test]
fn the_automated_aa_count_stays_within_the_wcag22_catalogue() {
let (scoped, total) = coverage_stats();
assert_eq!(total, WCAG_AA_TOTAL);
assert!(
scoped <= total,
"{scoped} automated A/AA criteria against a catalogue of {total}"
);
}
#[test]
fn parsing_is_no_longer_a_checked_criterion() {
assert!(
!automated_criteria().iter().any(|(id, _)| *id == "4.1.1"),
"WCAG 4.1.1 was removed in WCAG 2.2 and must not be reported as checked"
);
}
#[test]
fn hint_only_criteria_are_catalogued_and_listed_for_manual_review() {
for id in HINT_ONLY_CRITERIA {
let wcag_ref = format!("WCAG {id}");
let level = RULES
.iter()
.find(|r| r.external_ref == Some(wcag_ref.as_str()))
.and_then(|r| r.external_level)
.unwrap_or_else(|| panic!("hint-only criterion {id} has no rule in the catalog"));
assert!(
!automated_criteria().iter().any(|(aid, _)| aid == id),
"hint-only criterion {id} must not count as automated"
);
let listed = manual_review_criteria().iter().any(|(mid, _, _)| mid == id);
if level == "AAA" {
assert!(
!listed,
"AAA criterion {id} must stay out of the A/AA manual list"
);
} else {
assert!(
listed,
"hint-only criterion {id} must be listed for manual review"
);
}
}
}
#[test]
fn manual_review_criterion_name_de_has_a_real_translation_for_every_current_entry() {
for (id, _level, name_en) in manual_review_criteria() {
let name_de = manual_review_criterion_name_de(id, name_en);
assert_ne!(
name_de, *name_en,
"expected a German translation for WCAG {id}, got the English fallback"
);
}
}
#[test]
fn manual_review_criterion_name_de_matches_known_translation() {
assert_eq!(
manual_review_criterion_name_de("1.2.1", "Audio-only and Video-only (Prerecorded)"),
"Nur Audio oder nur Video (aufgezeichnet)"
);
}
#[test]
fn manual_review_criterion_name_de_falls_back_for_unknown_id() {
assert_eq!(
manual_review_criterion_name_de("9.9.9", "Unknown Criterion"),
"Unknown Criterion"
);
}
}