Skip to main content

ssg_a11y/
report.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Construction of the WCAG 2.2 compliance matrix.
5
6use std::collections::HashSet;
7
8use crate::types::{
9    CriterionEntry,
10    CriterionStatus::{self, Automated, Manual, NotApplicable, Runtime},
11    WcagComplianceReport,
12};
13
14/// Constructs the WCAG 2.2 compliance matrix. Marks `all_pages_pass=false`
15/// for any criterion that produced at least one issue across the scan.
16///
17/// `failed` should contain the [`AccessibilityIssue::criterion`](crate::AccessibilityIssue::criterion)
18/// of every issue raised by [`crate::check_page`] across all scanned pages.
19pub fn build_compliance_report(
20    pages_scanned: usize,
21    failed: &HashSet<String>,
22) -> WcagComplianceReport {
23    let did_pass = |sc: &str| !failed.contains(sc);
24    let row = |sc: &str, level: &str, title: &str, status: CriterionStatus| {
25        CriterionEntry {
26            criterion: sc.to_string(),
27            level: level.to_string(),
28            title: title.to_string(),
29            status,
30            all_pages_pass: matches!(status, Automated) && did_pass(sc),
31        }
32    };
33
34    let criteria = vec![
35        // Perceivable
36        row("1.1.1", "A", "Non-text Content", Automated),
37        row("1.3.1", "A", "Info and Relationships", Automated),
38        row("1.4.3", "AA", "Contrast (Minimum)", Runtime),
39        row("1.4.10", "AA", "Reflow", Runtime),
40        row("1.4.11", "AA", "Non-text Contrast", Runtime),
41        row("1.4.12", "AA", "Text Spacing", Runtime),
42        // Operable
43        row("2.3.1", "A", "Three Flashes or Below Threshold", Automated),
44        row("2.4.4", "A", "Link Purpose (In Context)", Automated),
45        row("2.4.11", "AA", "Focus Not Obscured (Minimum)", Runtime),
46        row("2.4.13", "AAA", "Focus Appearance", Automated),
47        row("2.5.7", "AA", "Dragging Movements", Manual),
48        row("2.5.8", "AA", "Target Size (Minimum)", Automated),
49        // Understandable
50        row("3.1.1", "A", "Language of Page", Automated),
51        // 3.2.6 requires cross-page analysis (consistent placement of
52        // a help mechanism); the per-page validator can't decide it.
53        row("3.2.6", "A", "Consistent Help", Manual),
54        row("3.3.7", "A", "Redundant Entry", NotApplicable),
55        row(
56            "3.3.8",
57            "AA",
58            "Accessible Authentication (Minimum)",
59            NotApplicable,
60        ),
61        // Robust
62        row("4.1.3", "AA", "Status Messages", Runtime),
63    ];
64
65    WcagComplianceReport {
66        wcag_version: "2.2".to_string(),
67        pages_scanned,
68        criteria,
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn build_compliance_report_marks_failed_criterion_as_not_passing() {
78        let mut failed = HashSet::new();
79        let _ = failed.insert("1.1.1".to_string());
80        let report = build_compliance_report(3, &failed);
81        assert_eq!(report.wcag_version, "2.2");
82        assert_eq!(report.pages_scanned, 3);
83
84        let img_alt = report
85            .criteria
86            .iter()
87            .find(|c| c.criterion == "1.1.1")
88            .expect("1.1.1 row must be present");
89        assert!(!img_alt.all_pages_pass);
90
91        // A different automated criterion with no failures still passes.
92        let lang = report
93            .criteria
94            .iter()
95            .find(|c| c.criterion == "3.1.1")
96            .expect("3.1.1 row must be present");
97        assert!(lang.all_pages_pass);
98
99        // Runtime/manual/not-applicable rows never report all_pages_pass.
100        let contrast = report
101            .criteria
102            .iter()
103            .find(|c| c.criterion == "1.4.3")
104            .expect("1.4.3 row must be present");
105        assert!(!contrast.all_pages_pass);
106    }
107}