Skip to main content

use_accessibility_score/
lib.rs

1#![forbid(unsafe_code)]
2//! Primitive accessibility scoring helpers.
3//!
4//! The score intentionally stays simple: `Pass` contributes full weight,
5//! `Warn` contributes half weight, and `Fail` contributes zero. The final score
6//! is normalized to a `0..=100` scale when total weight is positive.
7//!
8//! # Examples
9//!
10//! ```rust
11//! use use_accessibility_score::{
12//!     AccessibilityCheckResult, CheckStatus, count_failed, count_passed, count_warnings,
13//!     score_results,
14//! };
15//!
16//! let results = [
17//!     AccessibilityCheckResult {
18//!         name: String::from("contrast"),
19//!         status: CheckStatus::Pass,
20//!         weight: 2.0,
21//!     },
22//!     AccessibilityCheckResult {
23//!         name: String::from("label"),
24//!         status: CheckStatus::Warn,
25//!         weight: 1.0,
26//!     },
27//!     AccessibilityCheckResult {
28//!         name: String::from("focus"),
29//!         status: CheckStatus::Fail,
30//!         weight: 1.0,
31//!     },
32//! ];
33//! let score = score_results(&results).unwrap();
34//!
35//! assert_eq!(count_passed(&results), 1);
36//! assert_eq!(count_warnings(&results), 1);
37//! assert_eq!(count_failed(&results), 1);
38//! assert_eq!(score.passed, 1);
39//! assert_eq!(score.warnings, 1);
40//! assert_eq!(score.failed, 1);
41//! assert_eq!(score.score, 62.5);
42//! ```
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum CheckStatus {
46    Pass,
47    Warn,
48    Fail,
49}
50
51#[derive(Debug, Clone, PartialEq)]
52pub struct AccessibilityCheckResult {
53    pub name: String,
54    pub status: CheckStatus,
55    pub weight: f64,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq)]
59pub struct AccessibilityScore {
60    pub passed: usize,
61    pub warnings: usize,
62    pub failed: usize,
63    pub score: f64,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum AccessibilityScoreError {
68    InvalidWeight,
69}
70
71#[must_use]
72pub fn count_passed(results: &[AccessibilityCheckResult]) -> usize {
73    results
74        .iter()
75        .filter(|result| result.status == CheckStatus::Pass)
76        .count()
77}
78
79#[must_use]
80pub fn count_warnings(results: &[AccessibilityCheckResult]) -> usize {
81    results
82        .iter()
83        .filter(|result| result.status == CheckStatus::Warn)
84        .count()
85}
86
87#[must_use]
88pub fn count_failed(results: &[AccessibilityCheckResult]) -> usize {
89    results
90        .iter()
91        .filter(|result| result.status == CheckStatus::Fail)
92        .count()
93}
94
95pub fn score_results(
96    results: &[AccessibilityCheckResult],
97) -> Result<AccessibilityScore, AccessibilityScoreError> {
98    if results
99        .iter()
100        .any(|result| !result.weight.is_finite() || result.weight < 0.0)
101    {
102        return Err(AccessibilityScoreError::InvalidWeight);
103    }
104
105    let total_weight = results.iter().map(|result| result.weight).sum::<f64>();
106    let earned_weight = results.iter().fold(0.0, |sum, result| {
107        sum + match result.status {
108            CheckStatus::Pass => result.weight,
109            CheckStatus::Warn => result.weight * 0.5,
110            CheckStatus::Fail => 0.0,
111        }
112    });
113
114    Ok(AccessibilityScore {
115        passed: count_passed(results),
116        warnings: count_warnings(results),
117        failed: count_failed(results),
118        score: if total_weight == 0.0 {
119            0.0
120        } else {
121            (earned_weight / total_weight) * 100.0
122        },
123    })
124}
125
126#[cfg(test)]
127mod tests {
128    use super::{
129        AccessibilityCheckResult, AccessibilityScoreError, CheckStatus, count_failed, count_passed,
130        count_warnings, score_results,
131    };
132
133    #[test]
134    fn scores_accessibility_results() {
135        let results = [
136            AccessibilityCheckResult {
137                name: String::from("contrast"),
138                status: CheckStatus::Pass,
139                weight: 2.0,
140            },
141            AccessibilityCheckResult {
142                name: String::from("label"),
143                status: CheckStatus::Warn,
144                weight: 1.0,
145            },
146            AccessibilityCheckResult {
147                name: String::from("focus"),
148                status: CheckStatus::Fail,
149                weight: 1.0,
150            },
151        ];
152        let score = score_results(&results).unwrap();
153
154        assert_eq!(count_passed(&results), 1);
155        assert_eq!(count_warnings(&results), 1);
156        assert_eq!(count_failed(&results), 1);
157        assert_eq!(score.passed, 1);
158        assert_eq!(score.warnings, 1);
159        assert_eq!(score.failed, 1);
160        assert_eq!(score.score, 62.5);
161    }
162
163    #[test]
164    fn handles_empty_and_invalid_weights() {
165        let empty = score_results(&[]).unwrap();
166        assert_eq!(empty.score, 0.0);
167
168        let invalid = [AccessibilityCheckResult {
169            name: String::from("contrast"),
170            status: CheckStatus::Pass,
171            weight: -1.0,
172        }];
173
174        assert_eq!(
175            score_results(&invalid),
176            Err(AccessibilityScoreError::InvalidWeight)
177        );
178    }
179}