Skip to main content

a11y_report/
lib.rs

1//! Gemeinsames Befund- und Berichtsmodell für Accessibility-Werkzeuge.
2//!
3//! Ein Regelbestand bedient drei Oberflächen — Build-Zeit, CI/Crawl und die
4//! laufende Seite — mit identischen Regelkennungen und identischem JSON.
5//! Dieses Crate definiert, was dabei herauskommt.
6//!
7//! # Zwei Achsen, nicht drei
8//!
9//! [`Outcome`] sagt, *wie sicher* die Aussage ist; [`Severity`] sagt, *wie
10//! schwer* das Problem wiegt. Eine dritte Achse „certainty" gibt es bewusst
11//! nicht — eine nur heuristisch belegbare Regel liefert [`Outcome::Review`],
12//! nicht [`Outcome::Fail`] mit niedriger Gewissheit.
13//!
14//! ```
15//! use a11y_report::{Finding, Location, Report, Severity};
16//!
17//! let mut report = Report::new();
18//!
19//! // Eindeutig: das Attribut fehlt.
20//! report.push(
21//!     Finding::fail("images/alt-missing", "Das Bild besitzt kein alt-Attribut.")
22//!         .with_severity(Severity::High)
23//!         .with_wcag(["1.1.1"])
24//!         .at(Location::file("about/index.html").with_selector("main > img:nth-child(2)")),
25//! );
26//!
27//! // Heuristisch: der Alt-Text ist da, aber womöglich nichtssagend.
28//! report.push(
29//!     Finding::review("images/alt-suspicious", "Der Alt-Text sieht nach einem Dateinamen aus.")
30//!         .with_severity(Severity::Medium)
31//!         .with_wcag(["1.1.1"]),
32//! );
33//!
34//! let report = report.finish();
35//! assert_eq!(report.summary.fail, 1);
36//! assert_eq!(report.summary.review, 1);
37//! assert_eq!(report.summary.problems(), 2);
38//! ```
39//!
40//! # Regeln, die nicht laufen konnten
41//!
42//! Der Unterschied zwischen „lief und fand nichts" und „konnte nicht laufen"
43//! wird über [`RuleRun`] festgehalten. Ohne ihn liest sich eine Kontrastprüfung
44//! ohne Rendering-Zugriff wie eine bestandene Prüfung.
45//!
46//! ```
47//! use a11y_report::{NotRun, Report, RuleRun};
48//!
49//! let mut report = Report::new();
50//! report.record(
51//!     RuleRun::not_run("contrast/text", NotRun::CapabilityMissing)
52//!         .with_reason("statische Analyse liefert keine Rendering-Werte"),
53//! );
54//! let report = report.finish();
55//! assert_eq!(report.summary.rules_not_run, 1);
56//! ```
57
58#![forbid(unsafe_code)]
59
60mod extra;
61mod finding;
62mod outcome;
63mod report;
64
65pub use extra::Extra;
66pub use finding::{Evidence, Finding, Location};
67pub use outcome::{Outcome, Severity, WcagLevel};
68pub use report::{NotRun, Report, RuleRun, Summary};
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn zaehlt_zustaende_und_schweregrade_getrennt() {
76        let mut r = Report::new();
77        r.push(Finding::fail("a", "x").with_severity(Severity::Critical));
78        r.push(Finding::review("b", "x").with_severity(Severity::Low));
79        r.push(Finding::pass("c", "x").with_severity(Severity::Critical));
80        r.push(Finding::untested("d", "x").with_severity(Severity::High));
81        let r = r.finish();
82
83        assert_eq!(
84            (
85                r.summary.fail,
86                r.summary.review,
87                r.summary.pass,
88                r.summary.untested
89            ),
90            (1, 1, 1, 1)
91        );
92        // Schweregrade zaehlen nur ueber Probleme: das Critical des Pass und das
93        // High des Untested bleiben aussen vor.
94        assert_eq!(
95            (r.summary.critical, r.summary.high, r.summary.low),
96            (1, 0, 1)
97        );
98        assert_eq!(r.summary.problems(), 2);
99    }
100
101    #[test]
102    fn pass_wird_standardmaessig_nicht_angezeigt() {
103        let mut r = Report::new();
104        r.push(Finding::fail("a", "x"));
105        r.push(Finding::pass("b", "x"));
106        r.push(Finding::untested("c", "x"));
107        assert_eq!(r.visible().count(), 2);
108        assert_eq!(r.by_outcome(Outcome::Pass).count(), 1);
109    }
110
111    #[test]
112    fn untested_ist_kein_problem() {
113        assert!(Outcome::Fail.is_problem());
114        assert!(Outcome::Review.is_problem());
115        assert!(!Outcome::Pass.is_problem());
116        assert!(!Outcome::Untested.is_problem());
117    }
118
119    #[test]
120    fn json_bleibt_schlank_wenn_nichts_gesetzt_ist() {
121        let f = Finding::fail("images/alt-missing", "kein alt");
122        let j: serde_json::Value = serde_json::to_value(&f).unwrap();
123        let obj = j.as_object().unwrap();
124        // Nur die vier Pflichtfelder landen im JSON.
125        assert_eq!(
126            obj.len(),
127            4,
128            "unerwartete Felder: {:?}",
129            obj.keys().collect::<Vec<_>>()
130        );
131        assert_eq!(obj["outcome"], "fail");
132        assert_eq!(obj["severity"], "medium");
133    }
134
135    #[test]
136    fn json_rundlauf_erhaelt_alles() {
137        let f = Finding::review("aria/role-invalid", "unbekannte Rolle")
138            .with_severity(Severity::High)
139            .with_wcag(["4.1.2"])
140            .with_wcag_level(WcagLevel::A)
141            .with_tags(["best-practice"])
142            .with_help("Nur Rollen aus der ARIA-Spezifikation verwenden.")
143            .with_help_url("https://www.w3.org/TR/wai-aria-1.2/#roles")
144            .with_suggestion("role=\"buton\" ist vermutlich role=\"button\".")
145            .with_suggested_code("<div role=\"button\">")
146            .with_snippet("<div role=\"buton\">")
147            .at(Location::node("42").with_selector("#menu > div"))
148            .add_evidence(Evidence::dom_attribute("role", Some("buton".into())));
149
150        let json = serde_json::to_string(&f).unwrap();
151        let back: Finding = serde_json::from_str(&json).unwrap();
152        assert_eq!(f, back);
153    }
154
155    #[test]
156    fn wcag_stufen_schliessen_ein() {
157        assert!(WcagLevel::A.included_in(WcagLevel::AA));
158        assert!(WcagLevel::AA.included_in(WcagLevel::AA));
159        assert!(!WcagLevel::AAA.included_in(WcagLevel::AA));
160        assert!(WcagLevel::AAA.included_in(WcagLevel::AAA));
161    }
162
163    #[test]
164    fn severity_ordnet_sich_aufsteigend() {
165        let mut v = vec![
166            Severity::High,
167            Severity::Low,
168            Severity::Critical,
169            Severity::Medium,
170        ];
171        v.sort();
172        assert_eq!(
173            v,
174            vec![
175                Severity::Low,
176                Severity::Medium,
177                Severity::High,
178                Severity::Critical
179            ]
180        );
181    }
182
183    #[test]
184    fn axe_impact_bleibt_kompatibel() {
185        assert_eq!(Severity::Low.as_axe_impact(), "minor");
186        assert_eq!(Severity::Medium.as_axe_impact(), "moderate");
187        assert_eq!(Severity::High.as_axe_impact(), "serious");
188        assert_eq!(Severity::Critical.as_axe_impact(), "critical");
189    }
190
191    #[test]
192    fn nicht_gelaufene_regeln_werden_gezaehlt() {
193        let mut r = Report::new();
194        r.record(RuleRun::ran("images/alt-missing", 3));
195        r.record(RuleRun::not_run("contrast/text", NotRun::CapabilityMissing));
196        r.record(RuleRun::not_run("forms/label", NotRun::NotApplicable));
197        let r = r.finish();
198        assert_eq!(r.summary.rules_not_run, 2);
199        assert!(r.rule_runs[0].did_run());
200        assert!(!r.rule_runs[1].did_run());
201    }
202
203    // --- Erweiterungsslot ------------------------------------------------
204
205    #[derive(Debug)]
206    struct Screenshot(Vec<u8>);
207
208    #[test]
209    fn beigabe_kommt_typisiert_zurueck() {
210        let f =
211            Finding::fail("images/alt-missing", "kein alt").with_extra(Screenshot(vec![1, 2, 3]));
212        assert_eq!(f.extra.get::<Screenshot>().map(|s| s.0.len()), Some(3));
213        // Eine andere Sorte liegt nicht darin -- kein falscher Treffer.
214        assert!(f.extra.get::<String>().is_none());
215    }
216
217    #[test]
218    fn beigabe_landet_nicht_im_json() {
219        let f = Finding::fail("a", "x").with_extra(Screenshot(vec![0; 4096]));
220        let j = serde_json::to_string(&f).unwrap();
221        assert!(
222            !j.contains("extra"),
223            "Beigabe darf nicht serialisiert werden: {j}"
224        );
225        // Und sie blaeht den Vertrag nicht auf.
226        assert!(j.len() < 200, "{} Bytes", j.len());
227    }
228
229    #[test]
230    fn beigabe_zaehlt_nicht_zur_identitaet() {
231        // Sonst waeren zwei inhaltlich gleiche Befunde ungleich, nur weil an
232        // einem ein Screenshot haengt.
233        let ohne = Finding::fail("a", "x");
234        let mit = Finding::fail("a", "x").with_extra(Screenshot(vec![9]));
235        assert_eq!(ohne, mit);
236    }
237
238    #[test]
239    fn beigabe_ueberlebt_das_klonen() {
240        let f = Finding::fail("a", "x").with_extra(Screenshot(vec![7, 7]));
241        let k = f.clone();
242        assert_eq!(
243            k.extra.get::<Screenshot>().map(|s| s.0.clone()),
244            Some(vec![7, 7])
245        );
246    }
247
248    // --- Rolle, Name, Regelname ------------------------------------------
249
250    #[test]
251    fn element_und_regelname_gehen_durch_das_json() {
252        let f = Finding::fail("buttons/name-missing", "kein Name")
253            .with_rule_name("Button braucht einen zugänglichen Namen")
254            .with_element(Some("button".into()), None);
255
256        let j: serde_json::Value = serde_json::to_value(&f).unwrap();
257        assert_eq!(j["role"], "button");
258        assert_eq!(j["rule_name"], "Button braucht einen zugänglichen Namen");
259        // Ein fehlender Name ist kein leerer Name -- das Feld faellt weg.
260        assert!(j.get("name").is_none());
261
262        let zurueck: Finding = serde_json::from_value(j).unwrap();
263        assert_eq!(zurueck, f);
264    }
265
266    #[test]
267    fn der_schlanke_fall_bleibt_schlank() {
268        // Die neuen Felder duerfen den Vertrag nicht aufblaehen, wenn sie
269        // ungesetzt sind.
270        let f = Finding::fail("images/alt-missing", "kein alt");
271        let j: serde_json::Value = serde_json::to_value(&f).unwrap();
272        assert_eq!(
273            j.as_object().unwrap().len(),
274            4,
275            "unerwartete Felder: {:?}",
276            j.as_object().unwrap().keys().collect::<Vec<_>>()
277        );
278    }
279
280    // --- Vermerke je Durchgang -------------------------------------------
281
282    #[test]
283    fn derselbe_regelvermerk_kann_je_durchgang_vorkommen() {
284        // Wer dieselbe Seite unter mehreren Bedingungen prueft, fuehrt je
285        // Durchgang einen Vermerk. Der Schluessel ist dann (rule_id, viewport).
286        let mut r = Report::new();
287        r.record(RuleRun::ran("target-size/minimum", 0).in_viewport("desktop"));
288        r.record(RuleRun::ran("target-size/minimum", 3).in_viewport("mobile"));
289        let r = r.finish();
290
291        assert_eq!(r.rule_runs.len(), 2);
292        let mobil = r
293            .rule_runs
294            .iter()
295            .find(|x| x.viewport.as_deref() == Some("mobile"))
296            .unwrap();
297        assert_eq!(mobil.findings, 3);
298    }
299
300    #[test]
301    fn ein_nicht_gelaufener_vermerk_nennt_das_ungeprueffte_kriterium() {
302        let mut r = Report::new();
303        r.record(
304            RuleRun::not_run("contrast/text", NotRun::CapabilityMissing)
305                .with_wcag(["1.4.3"])
306                .with_reason("statische Analyse liefert keine Rendering-Werte"),
307        );
308        let r = r.finish();
309        assert_eq!(r.summary.rules_not_run, 1);
310        assert_eq!(r.rule_runs[0].wcag, vec!["1.4.3"]);
311    }
312}