Skip to main content

a11y_report/
report.rs

1//! Der Bericht: Befunde plus die Frage, ob jede Regel überhaupt laufen konnte.
2
3use serde::{Deserialize, Serialize};
4
5use crate::finding::Finding;
6use crate::outcome::{Outcome, Severity};
7
8/// Warum eine Regel nicht gelaufen ist.
9///
10/// Der Unterschied zwischen „lief und fand nichts" und „konnte nicht laufen"
11/// ist der Kern des Vier-Zustands-Modells. Ohne ihn liest sich eine
12/// Kontrastprüfung ohne Rendering-Zugriff wie eine bestandene Prüfung.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum NotRun {
16    /// Der Host liefert die nötigen Daten nicht — z. B. Rendering-Werte bei
17    /// statischer HTML-Analyse. Die Regel ist nicht anwendbar, nicht bestanden.
18    CapabilityMissing,
19    /// Die Regel wurde per Konfiguration abgeschaltet.
20    Disabled,
21    /// Auf dieser Seite gibt es nichts zu prüfen — kein Formular, kein Video.
22    NotApplicable,
23    /// Die Regel ist gelaufen und hat einen Fehler geworfen.
24    Errored,
25}
26
27/// Ein Ausführungsvermerk je Regel. Beantwortet „lief das überhaupt?", während
28/// [`Finding`] beantwortet „was kam dabei heraus?".
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct RuleRun {
31    pub rule_id: String,
32    /// Der Durchgang, in dem diese Regel lief — etwa `desktop` oder `mobile`.
33    ///
34    /// Werkzeuge, die dieselbe Seite mehrfach unter verschiedenen Bedingungen
35    /// prüfen, führen je Durchgang einen eigenen Vermerk. Der Schlüssel eines
36    /// Vermerks ist dann `(rule_id, viewport)`, nicht `rule_id` allein.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub viewport: Option<String>,
39    /// Die Erfolgskriterien, die mit dieser Regel stehen und fallen. Nötig,
40    /// damit ein Bericht auch für eine **nicht** gelaufene Regel sagen kann,
41    /// welches Kriterium ungeprüft blieb.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub wcag: Vec<String>,
44    /// `None` = gelaufen. `Some(_)` = nicht gelaufen, mit Grund.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub not_run: Option<NotRun>,
47    /// Wie viele Befunde diese Regel beigetragen hat.
48    #[serde(default)]
49    pub findings: usize,
50    /// Freitext zur Einordnung, etwa welche Fähigkeit gefehlt hat.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub reason: Option<String>,
53}
54
55impl RuleRun {
56    pub fn ran(rule_id: impl Into<String>, findings: usize) -> Self {
57        RuleRun {
58            rule_id: rule_id.into(),
59            viewport: None,
60            wcag: Vec::new(),
61            not_run: None,
62            findings,
63            reason: None,
64        }
65    }
66
67    pub fn not_run(rule_id: impl Into<String>, why: NotRun) -> Self {
68        RuleRun {
69            rule_id: rule_id.into(),
70            viewport: None,
71            wcag: Vec::new(),
72            not_run: Some(why),
73            findings: 0,
74            reason: None,
75        }
76    }
77
78    /// Hält fest, in welchem Durchgang die Regel lief.
79    pub fn in_viewport(mut self, v: impl Into<String>) -> Self {
80        self.viewport = Some(v.into());
81        self
82    }
83
84    pub fn with_wcag(mut self, criteria: impl IntoIterator<Item = impl Into<String>>) -> Self {
85        self.wcag = criteria.into_iter().map(Into::into).collect();
86        self
87    }
88
89    pub fn with_reason(mut self, r: impl Into<String>) -> Self {
90        self.reason = Some(r.into());
91        self
92    }
93
94    pub fn did_run(&self) -> bool {
95        self.not_run.is_none()
96    }
97}
98
99/// Zählwerk über einen Befundsatz. Bewusst keine Gesamtnote: Ein aggregierter
100/// Score würde genau die Unterscheidung einebnen, für die es die vier Zustände
101/// gibt.
102#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
103pub struct Summary {
104    pub fail: usize,
105    pub review: usize,
106    pub pass: usize,
107    pub untested: usize,
108    pub critical: usize,
109    pub high: usize,
110    pub medium: usize,
111    pub low: usize,
112    /// Regeln, die nicht laufen konnten.
113    pub rules_not_run: usize,
114}
115
116impl Summary {
117    /// Zählt Probleme: `Fail` und `Review`. `Untested` zählt nicht mit, weil es
118    /// gar keine Aussage trifft.
119    pub fn problems(&self) -> usize {
120        self.fail + self.review
121    }
122}
123
124/// Das Gesamtergebnis eines Laufs.
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126pub struct Report {
127    pub findings: Vec<Finding>,
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub rule_runs: Vec<RuleRun>,
130    pub summary: Summary,
131}
132
133impl Report {
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    pub fn push(&mut self, f: Finding) {
139        self.findings.push(f);
140    }
141
142    pub fn extend(&mut self, fs: impl IntoIterator<Item = Finding>) {
143        self.findings.extend(fs);
144    }
145
146    pub fn record(&mut self, r: RuleRun) {
147        self.rule_runs.push(r);
148    }
149
150    /// Nur die Befunde eines Zustands.
151    pub fn by_outcome(&self, o: Outcome) -> impl Iterator<Item = &Finding> {
152        self.findings.iter().filter(move |f| f.outcome == o)
153    }
154
155    /// Was standardmäßig angezeigt wird — alles außer `Pass`.
156    pub fn visible(&self) -> impl Iterator<Item = &Finding> {
157        self.findings
158            .iter()
159            .filter(|f| f.outcome.is_visible_by_default())
160    }
161
162    /// Rechnet [`Report::summary`] aus den aktuellen Befunden neu.
163    /// Muss nach dem Befüllen einmal aufgerufen werden.
164    pub fn finish(mut self) -> Self {
165        let mut s = Summary::default();
166        for f in &self.findings {
167            match f.outcome {
168                Outcome::Fail => s.fail += 1,
169                Outcome::Review => s.review += 1,
170                Outcome::Pass => s.pass += 1,
171                Outcome::Untested => s.untested += 1,
172            }
173            // Severity wird nur über Probleme gezählt; ein bestandener Test hat
174            // keine Schwere.
175            if f.outcome.is_problem() {
176                match f.severity {
177                    Severity::Critical => s.critical += 1,
178                    Severity::High => s.high += 1,
179                    Severity::Medium => s.medium += 1,
180                    Severity::Low => s.low += 1,
181                }
182            }
183        }
184        s.rules_not_run = self.rule_runs.iter().filter(|r| !r.did_run()).count();
185        self.summary = s;
186        self
187    }
188}