use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Scaling {
Linear,
Logarithmic,
Fixed,
}
#[derive(Debug, Clone, Copy)]
pub struct ScoreImpact {
pub base_penalty: f32,
pub max_penalty: f32,
pub occurrence_scaling: Scaling,
}
pub const SATURATION_THRESHOLD: usize = 10;
const PHASE2_RATE: f32 = 0.05;
impl ScoreImpact {
pub fn calculate_penalty(&self, occurrences: usize) -> f32 {
if occurrences == 0 {
return 0.0;
}
match self.occurrence_scaling {
Scaling::Fixed => self.base_penalty.min(self.max_penalty),
Scaling::Linear => (occurrences as f32 * self.base_penalty).min(self.max_penalty),
Scaling::Logarithmic => {
let phase1_count = occurrences.min(SATURATION_THRESHOLD);
let phase2_count = occurrences.saturating_sub(SATURATION_THRESHOLD);
let phase1 =
(self.base_penalty * (1.0 + (phase1_count as f32).ln())).min(self.max_penalty);
let phase2 = phase2_count as f32 * self.base_penalty * PHASE2_RATE;
(phase1 + phase2).min(self.max_penalty * 2.0)
}
}
}
}
pub const MODULE_WEIGHTS: &[(&str, u32)] = &[
("Accessibility", 40),
("Performance", 20),
("SEO", 15),
("Security", 10),
("Mobile", 10),
("HTML Conformance", 5),
];
pub fn module_derived_from(name: &str) -> &'static [&'static str] {
match name {
"UX" | "Journey" => &["accessibility"],
"AI Visibility" => &["seo"],
"Source Quality" => &["seo", "security", "accessibility"],
_ => &[],
}
}
pub fn module_weight(name: &str) -> u32 {
MODULE_WEIGHTS
.iter()
.find(|(module, _)| *module == name)
.map(|(_, weight)| *weight)
.unwrap_or(0)
}
pub fn score_label(score: u32) -> &'static str {
match score {
90..=100 => "Sehr gut",
75..=89 => "Gut",
60..=74 => "Verbesserungswürdig",
40..=59 => "Ausbaufähig",
_ => "Kritisch",
}
}
pub fn score_grade(score: u32) -> &'static str {
crate::registry::LETTER_GRADE.label(score as f32, false)
}
pub fn module_score_grade(score: u32) -> &'static str {
crate::registry::FIVE_BAND_LETTERS.label(score as f32, false)
}
pub fn score_status(score: u32) -> &'static str {
match score {
80..=100 => "good",
60..=79 => "warning",
_ => "bad",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fixed_penalty() {
let impact = ScoreImpact {
base_penalty: 10.0,
max_penalty: 10.0,
occurrence_scaling: Scaling::Fixed,
};
assert_eq!(impact.calculate_penalty(1), 10.0);
assert_eq!(impact.calculate_penalty(5), 10.0);
}
#[test]
fn test_linear_penalty_capped() {
let impact = ScoreImpact {
base_penalty: 3.0,
max_penalty: 10.0,
occurrence_scaling: Scaling::Linear,
};
assert_eq!(impact.calculate_penalty(1), 3.0);
assert_eq!(impact.calculate_penalty(3), 9.0);
assert_eq!(impact.calculate_penalty(5), 10.0); }
#[test]
fn test_logarithmic_penalty() {
let impact = ScoreImpact {
base_penalty: 3.0,
max_penalty: 15.0,
occurrence_scaling: Scaling::Logarithmic,
};
let p1 = impact.calculate_penalty(1);
let p3 = impact.calculate_penalty(3);
let p10 = impact.calculate_penalty(10);
assert!(p1 < p3);
assert!(p3 < p10);
assert!(p10 <= 15.0);
}
#[test]
fn test_depth_saturation_phase2() {
let impact = ScoreImpact {
base_penalty: 3.0,
max_penalty: 10.0,
occurrence_scaling: Scaling::Logarithmic,
};
let p10 = impact.calculate_penalty(10);
let p50 = impact.calculate_penalty(50);
let p200 = impact.calculate_penalty(200);
assert!(p50 > p10, "p50={} should exceed p10={}", p50, p10);
assert!(p200 > p50, "p200={} should exceed p50={}", p200, p50);
assert!(p200 <= 20.0, "p200={} should not exceed 2×max", p200);
assert!((impact.calculate_penalty(2) - 3.0 * (1.0 + 2.0_f32.ln())).abs() < 0.01);
}
#[test]
fn test_score_labels() {
assert_eq!(score_label(95), "Sehr gut");
assert_eq!(score_label(80), "Gut");
assert_eq!(score_label(65), "Verbesserungswürdig");
assert_eq!(score_label(45), "Ausbaufähig");
assert_eq!(score_label(20), "Kritisch");
}
#[test]
fn test_module_score_grade_edges() {
assert_eq!(module_score_grade(100), "A");
assert_eq!(module_score_grade(90), "A");
assert_eq!(module_score_grade(89), "B");
assert_eq!(module_score_grade(75), "B");
assert_eq!(module_score_grade(74), "C");
assert_eq!(module_score_grade(60), "C");
assert_eq!(module_score_grade(59), "D");
assert_eq!(module_score_grade(40), "D");
assert_eq!(module_score_grade(39), "F");
}
#[test]
fn module_weights_sum_to_100() {
let total: u32 = MODULE_WEIGHTS.iter().map(|(_, w)| w).sum();
assert_eq!(total, 100, "{MODULE_WEIGHTS:?}");
}
#[test]
fn build_module_scores_reads_its_weights_from_this_table() {
let normalized = crate::audit::normalized::normalize(&crate::audit::AuditReport::new(
"https://example.com".to_string(),
crate::WcagLevel::AA,
crate::wcag::WcagResults::new(),
100,
))
.normalized;
for entry in &normalized.module_scores {
assert_eq!(
entry.weight_pct,
module_weight(&entry.name),
"{} carries a weight this table does not know",
entry.name,
);
}
}
#[test]
fn module_weight_is_zero_for_modules_outside_the_overall_score() {
assert_eq!(module_weight("Accessibility"), 40);
assert_eq!(module_weight("HTML Conformance"), 5);
assert_eq!(module_weight("UX"), 0);
assert_eq!(module_weight("Journey"), 0);
assert_eq!(module_weight("No such module"), 0);
}
}