use std::collections::{BTreeMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::taxonomy::{
criterion_for_rule, principle_for_criterion, RuleLookup, Scaling, ScoreImpact, WcagPrinciple,
};
use crate::wcag::coverage::automated_criteria;
use crate::wcag::types::{Severity, Violation};
fn checked_criteria_count(principle: WcagPrinciple) -> usize {
automated_criteria()
.iter()
.filter(|(c, _)| principle_for_criterion(c) == Some(principle))
.count()
}
pub struct AccessibilityScorer;
const SOFT_FLOOR_START: f32 = 15.0;
const COMPRESSION_KNEE: f32 = 70.0;
fn diversity_factor(unique_criteria: usize) -> f32 {
if unique_criteria <= 3 {
1.0
} else {
(1.0 + 0.15 * (1.0 + (unique_criteria as f32 / 5.0).ln())).min(1.5)
}
}
fn apply_soft_floor(raw_score: f32) -> f32 {
if raw_score >= SOFT_FLOOR_START {
raw_score
} else {
SOFT_FLOOR_START * ((raw_score - SOFT_FLOOR_START) / 8.0).exp()
}
}
pub(crate) fn default_impact(severity: Severity) -> ScoreImpact {
match severity {
Severity::Critical => ScoreImpact {
base_penalty: 5.0,
max_penalty: 15.0,
occurrence_scaling: Scaling::Logarithmic,
},
Severity::High => ScoreImpact {
base_penalty: 3.0,
max_penalty: 10.0,
occurrence_scaling: Scaling::Logarithmic,
},
Severity::Medium => ScoreImpact {
base_penalty: 1.5,
max_penalty: 5.0,
occurrence_scaling: Scaling::Logarithmic,
},
Severity::Low => ScoreImpact {
base_penalty: 0.5,
max_penalty: 2.0,
occurrence_scaling: Scaling::Fixed,
},
}
}
pub(crate) fn score_from_penalties(
raw_penalty: f32,
unique_criteria: usize,
critical_occurrences: usize,
urgent_occurrences: usize,
level_a_urgent_occurrences: usize,
) -> f32 {
if raw_penalty <= 0.0 {
return 100.0;
}
let total_penalty = raw_penalty * diversity_factor(unique_criteria);
let effective_penalty = if total_penalty > COMPRESSION_KNEE {
COMPRESSION_KNEE + (total_penalty - COMPRESSION_KNEE).sqrt() * 2.0
} else {
total_penalty
};
let raw_score = apply_soft_floor(100.0 - effective_penalty).min(100.0);
let score_cap: f32 = if critical_occurrences >= 1 {
49.0
} else if level_a_urgent_occurrences >= 1 {
89.0
} else if urgent_occurrences >= 5 {
92.0
} else {
100.0
};
raw_score.min(score_cap)
}
impl AccessibilityScorer {
pub fn calculate_score(violations: &[Violation]) -> f32 {
if violations.is_empty() {
return 100.0;
}
let mut rule_counts: BTreeMap<&str, (usize, Severity)> = BTreeMap::new();
for v in violations {
let entry = rule_counts.entry(&v.rule).or_insert((0, v.severity));
entry.0 += 1;
if v.severity > entry.1 {
entry.1 = v.severity;
}
}
let mut total_penalty = 0.0f32;
for (rule_id, (count, severity)) in &rule_counts {
let impact = RuleLookup::by_legacy_wcag_id(rule_id)
.map(|r| r.score_impact)
.unwrap_or_else(|| default_impact(*severity));
total_penalty += impact.calculate_penalty(*count);
}
let unique_criteria: HashSet<String> = rule_counts
.keys()
.map(|rule_id| criterion_for_rule(rule_id).unwrap_or_else(|| (*rule_id).to_string()))
.collect();
let critical_count = violations
.iter()
.filter(|v| matches!(v.severity, Severity::Critical))
.count();
let urgent_count = critical_count
+ violations
.iter()
.filter(|v| matches!(v.severity, Severity::High))
.count();
let level_a_urgent_count = violations
.iter()
.filter(|v| {
v.level == crate::cli::WcagLevel::A
&& matches!(v.severity, Severity::High | Severity::Critical)
})
.count();
score_from_penalties(
total_penalty,
unique_criteria.len(),
critical_count,
urgent_count,
level_a_urgent_count,
)
}
pub fn calculate_grade(score: f32) -> &'static str {
crate::registry::LETTER_GRADE.label(score, false)
}
pub fn calculate_certificate(score: f32) -> &'static str {
crate::registry::CERTIFICATE.label(score, false)
}
pub fn calculate_coverage(violations: &[Violation]) -> PrincipleCoverage {
let failed: HashSet<String> = violations
.iter()
.filter_map(|v| criterion_for_rule(&v.rule))
.collect();
let ratio_for = |principle: WcagPrinciple| -> CoverageRatio {
let total = checked_criteria_count(principle) as u32;
let failed_in_principle = failed
.iter()
.filter(|c| principle_for_criterion(c) == Some(principle))
.count() as u32;
let passed = total.saturating_sub(failed_in_principle);
CoverageRatio {
passed,
total,
ratio: if total == 0 {
1.0
} else {
let r = passed as f32 / total as f32;
(r * 10000.0).round() / 10000.0
},
}
};
PrincipleCoverage {
perceivable: ratio_for(WcagPrinciple::Perceivable),
operable: ratio_for(WcagPrinciple::Operable),
understandable: ratio_for(WcagPrinciple::Understandable),
robust: ratio_for(WcagPrinciple::Robust),
}
}
pub fn calculate_statistics(violations: &[Violation]) -> ViolationStatistics {
let total = violations.len();
let critical = violations
.iter()
.filter(|v| matches!(v.severity, Severity::Critical))
.count();
let high = violations
.iter()
.filter(|v| matches!(v.severity, Severity::High))
.count();
let medium = violations
.iter()
.filter(|v| matches!(v.severity, Severity::Medium))
.count();
let low = violations
.iter()
.filter(|v| matches!(v.severity, Severity::Low))
.count();
ViolationStatistics {
total,
critical,
high,
medium,
low,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CoverageRatio {
pub passed: u32,
pub total: u32,
pub ratio: f32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PrincipleCoverage {
pub perceivable: CoverageRatio,
pub operable: CoverageRatio,
pub understandable: CoverageRatio,
pub robust: CoverageRatio,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ViolationStatistics {
pub total: usize,
pub critical: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_perfect_score() {
let violations = vec![];
let score = AccessibilityScorer::calculate_score(&violations);
assert_eq!(score, 100.0);
assert_eq!(AccessibilityScorer::calculate_grade(score), "A");
assert_eq!(
AccessibilityScorer::calculate_certificate(score),
"SEHR GUT"
);
}
#[test]
fn test_score_with_errors() {
use crate::cli::WcagLevel;
let violations = vec![
Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"Image missing alt",
"img1",
),
Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"Image missing alt",
"img2",
),
];
let score = AccessibilityScorer::calculate_score(&violations);
assert_eq!(score, 89.0);
assert_eq!(AccessibilityScorer::calculate_grade(score), "B");
}
#[test]
fn test_score_with_warnings() {
use crate::cli::WcagLevel;
let violations = vec![Violation::new(
"2.4.4",
"Link Purpose",
WcagLevel::A,
Severity::Medium,
"Link text not descriptive",
"link1",
)];
let score = AccessibilityScorer::calculate_score(&violations);
assert_eq!(score, 99.0);
}
#[test]
fn test_critical_penalty_no_headings() {
use crate::cli::WcagLevel;
let violations = vec![Violation::new(
"2.4.6",
"Headings and Labels",
WcagLevel::AA,
Severity::High,
"No headings found",
"document",
)];
let score = AccessibilityScorer::calculate_score(&violations);
assert_eq!(score, 80.0);
assert_eq!(AccessibilityScorer::calculate_grade(score), "B");
assert_eq!(AccessibilityScorer::calculate_certificate(score), "GUT");
}
#[test]
fn test_score_floor_at_zero() {
use crate::cli::WcagLevel;
let rules = [
("1.1.1", "Non-text Content"),
("1.3.1", "Info and Relationships"),
("1.4.3", "Contrast"),
("2.1.1", "Keyboard"),
("2.4.1", "Bypass Blocks"),
("2.4.2", "Page Titled"),
("2.4.6", "Headings"),
("2.4.7", "Focus Visible"),
("3.1.1", "Language"),
("3.3.2", "Labels"),
("4.1.2", "Name Role Value"),
("2.4.4", "Link Purpose"),
("2.4.3", "Focus Order"),
("1.4.4", "Resize Text"),
];
let mut violations = Vec::new();
for (rule, name) in &rules {
for i in 0..20 {
violations.push(Violation::new(
*rule,
*name,
WcagLevel::A,
Severity::Critical,
"Error",
format!("n{}", i),
));
}
}
let score = AccessibilityScorer::calculate_score(&violations);
assert!(score < 10.0, "score was {}", score);
assert_eq!(AccessibilityScorer::calculate_grade(score), "F");
assert_eq!(
AccessibilityScorer::calculate_certificate(score),
"UNGENÜGEND"
);
}
#[test]
fn test_catastrophic_band_stays_discriminating() {
use crate::cli::WcagLevel;
let rules = [
("1.1.1", "Non-text Content"),
("1.3.1", "Info and Relationships"),
("1.4.3", "Contrast"),
("2.1.1", "Keyboard"),
("2.4.1", "Bypass Blocks"),
("2.4.2", "Page Titled"),
("2.4.6", "Headings"),
("2.4.7", "Focus Visible"),
("3.1.1", "Language"),
("3.3.2", "Labels"),
("4.1.2", "Name Role Value"),
("2.4.4", "Link Purpose"),
("2.4.3", "Focus Order"),
("1.4.4", "Resize Text"),
];
let build = |criteria: usize, occ: usize| -> Vec<Violation> {
let mut v = Vec::new();
for (rule, name) in rules.iter().take(criteria) {
for i in 0..occ {
v.push(Violation::new(
*rule,
*name,
WcagLevel::A,
Severity::Critical,
"Error",
format!("n{}", i),
));
}
}
v
};
let moderate = AccessibilityScorer::calculate_score(&build(4, 3));
let catastrophic = AccessibilityScorer::calculate_score(&build(14, 30));
assert!(
moderate - catastrophic >= 8.0,
"moderate {} should outscore catastrophic {} by a clear margin",
moderate,
catastrophic
);
assert!(catastrophic > 0.0);
}
#[test]
fn test_diversity_penalises_breadth() {
use crate::cli::WcagLevel;
let same: Vec<Violation> = (0..5)
.map(|i| {
Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"Image missing alt",
format!("n{}", i),
)
})
.collect();
let diverse_rules = [
("1.1.1", "Non-text Content"),
("1.3.1", "Info and Relationships"),
("2.4.3", "Focus Order"),
("3.3.2", "Labels"),
("1.4.4", "Resize Text"),
];
let diverse: Vec<Violation> = diverse_rules
.iter()
.map(|(rule, name)| {
Violation::new(*rule, *name, WcagLevel::A, Severity::High, "Error", "node")
})
.collect();
let score_same = AccessibilityScorer::calculate_score(&same);
let score_diverse = AccessibilityScorer::calculate_score(&diverse);
assert!(
score_diverse < score_same,
"diverse {} should score lower than same {}",
score_diverse,
score_same
);
}
#[test]
fn test_diversity_factor_growth() {
assert_eq!(diversity_factor(1), 1.0);
assert_eq!(diversity_factor(3), 1.0);
assert!(diversity_factor(5) > 1.0);
assert!(diversity_factor(20) > diversity_factor(5));
assert!(diversity_factor(40) <= 1.5);
}
#[test]
fn test_soft_floor_curve() {
assert_eq!(apply_soft_floor(20.0), 20.0);
assert_eq!(apply_soft_floor(50.0), 50.0);
assert!(apply_soft_floor(0.0) > 0.0);
assert!(apply_soft_floor(-30.0) > 0.0);
assert!(apply_soft_floor(-30.0) < apply_soft_floor(0.0));
assert!(apply_soft_floor(-60.0) < 1.0);
}
#[test]
fn test_coverage_all_pass_when_no_violations() {
let coverage = AccessibilityScorer::calculate_coverage(&[]);
for ratio in [
&coverage.perceivable,
&coverage.operable,
&coverage.understandable,
&coverage.robust,
] {
assert_eq!(ratio.passed, ratio.total);
assert!(ratio.total > 0);
assert_eq!(ratio.ratio, 1.0);
}
}
#[test]
fn test_coverage_reflects_violations() {
use crate::cli::WcagLevel;
let violations = vec![Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"Missing alt",
"n1",
)];
let coverage = AccessibilityScorer::calculate_coverage(&violations);
assert_eq!(coverage.perceivable.passed, coverage.perceivable.total - 1);
assert_eq!(coverage.operable.passed, coverage.operable.total);
assert_eq!(coverage.robust.passed, coverage.robust.total);
}
#[test]
fn test_statistics_calculation() {
use crate::cli::WcagLevel;
let violations = vec![
Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::Critical,
"Error",
"1",
),
Violation::new(
"2.4.6",
"Headings and Labels",
WcagLevel::AA,
Severity::Medium,
"Warning",
"2",
),
Violation::new(
"3.1.1",
"Language of Page",
WcagLevel::A,
Severity::Low,
"Notice",
"3",
),
Violation::new(
"4.1.2",
"Name, Role, Value",
WcagLevel::A,
Severity::High,
"Error",
"4",
),
];
let stats = AccessibilityScorer::calculate_statistics(&violations);
assert_eq!(stats.total, 4);
assert_eq!(stats.critical, 1);
assert_eq!(stats.high, 1);
assert_eq!(stats.medium, 1);
assert_eq!(stats.low, 1);
}
}
#[cfg(test)]
mod score_invariants {
use super::*;
use crate::cli::WcagLevel;
const RULES: &[&str] = &[
"1.1.1", "1.3.1", "1.4.3", "2.1.1", "2.4.2", "2.4.4", "3.1.1", "4.1.2", "x.1", "x.2",
"x.3", "x.4",
];
const SEVERITIES: &[Severity] = &[
Severity::Low,
Severity::Medium,
Severity::High,
Severity::Critical,
];
struct Lcg(u64);
impl Lcg {
fn next(&mut self, n: usize) -> usize {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
((self.0 >> 33) as usize) % n
}
}
fn violation(rule: &str, level: WcagLevel, severity: Severity, i: usize) -> Violation {
Violation::new(rule, rule, level, severity, "m", format!("n{i}"))
}
fn random_violation(rng: &mut Lcg, i: usize) -> Violation {
let level = if rng.next(2) == 0 {
WcagLevel::A
} else {
WcagLevel::AA
};
violation(
RULES[rng.next(RULES.len())],
level,
SEVERITIES[rng.next(4)],
i,
)
}
fn random_set(rng: &mut Lcg) -> Vec<Violation> {
(0..rng.next(25))
.map(|i| random_violation(rng, i))
.collect()
}
fn score(v: &[Violation]) -> f32 {
AccessibilityScorer::calculate_score(v)
}
const EPS: f32 = 1e-3;
#[test]
fn score_stays_within_bounds() {
let mut rng = Lcg(1);
for _ in 0..2000 {
let s = score(&random_set(&mut rng));
assert!((0.0..=100.0).contains(&s), "score {s} out of bounds");
}
}
#[test]
fn adding_a_finding_never_raises_the_score() {
let mut rng = Lcg(2);
for _ in 0..2000 {
let mut set = random_set(&mut rng);
let before = score(&set);
let i = set.len();
set.push(random_violation(&mut rng, i));
let after = score(&set);
assert!(
after <= before + EPS,
"adding {:?} raised the score {before} -> {after}",
set.last().map(|v| (&v.rule, v.severity))
);
}
}
#[test]
fn raising_a_severity_never_raises_the_score() {
let mut rng = Lcg(3);
for _ in 0..2000 {
let mut set = random_set(&mut rng);
if set.is_empty() {
continue;
}
let before = score(&set);
let i = rng.next(set.len());
let raised = SEVERITIES
.iter()
.copied()
.find(|s| *s > set[i].severity)
.unwrap_or(set[i].severity);
set[i].severity = raised;
let after = score(&set);
assert!(
after <= before + EPS,
"raising severity raised the score {before} -> {after}"
);
}
}
#[test]
fn caps_hold_for_any_set() {
let mut rng = Lcg(4);
for _ in 0..2000 {
let set = random_set(&mut rng);
let s = score(&set);
let critical = set
.iter()
.filter(|v| v.severity == Severity::Critical)
.count();
let urgent = critical + set.iter().filter(|v| v.severity == Severity::High).count();
let level_a_urgent = set
.iter()
.filter(|v| v.level == WcagLevel::A && v.severity >= Severity::High)
.count();
if critical > 0 {
assert!(s <= 49.0, "{s} with {critical} critical");
} else if level_a_urgent > 0 {
assert!(s <= 89.0, "{s} with {level_a_urgent} Level A high");
} else if urgent >= 5 {
assert!(s <= 92.0, "{s} with {urgent} critical/high");
}
}
}
#[test]
fn only_an_empty_set_scores_100() {
assert_eq!(score(&[]), 100.0);
for rule in RULES {
assert!(
score(&[violation(rule, WcagLevel::AA, Severity::Low, 0)]) < 100.0,
"{rule}"
);
}
}
}