use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::audit::scoring::{AccessibilityScorer, ViolationStatistics};
use crate::browser::ThrottleProfile;
use crate::cli::WcagLevel;
use crate::dark_mode::DarkModeAnalysis;
use crate::mobile::MobileFriendliness;
use crate::performance::{
AnimationAnalysis, ContentWeight, CoverageAnalysis, CriticalChain, MinificationAnalysis,
PerformanceScore, RenderBlockingAnalysis, ThirdPartyAttribution, WebVitals,
};
use crate::security::SecurityAnalysis;
use crate::seo::SeoAnalysis;
use crate::ux::UxAnalysis;
use crate::wcag::WcagResults;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThrottledPerfResult {
pub profile: ThrottleProfile,
pub lcp_ms: Option<f64>,
pub tbt_ms: Option<f64>,
pub cls: Option<f64>,
pub score: u32,
}
#[derive(Debug, Clone)]
pub struct PageScreenshots {
pub desktop: Vec<u8>,
pub mobile: Vec<u8>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "state", content = "reason")]
pub enum ScreenshotStatus {
Captured,
Failed(String),
#[default]
NotRequested,
}
#[derive(Debug, Clone)]
pub struct ViewportScreenshot {
pub bytes: Vec<u8>,
pub width: u32,
pub height: u32,
pub device_scale_factor: f64,
pub scroll_x: f64,
pub scroll_y: f64,
}
#[derive(Debug, Clone)]
pub struct ViewportAuditData {
pub wcag_results: WcagResults,
pub accessibility_score: f32,
pub performance: Option<PerformanceResults>,
pub seo: Option<SeoAnalysis>,
pub mobile: Option<MobileFriendliness>,
pub ux: Option<crate::ux::UxAnalysis>,
pub journey: Option<crate::journey::JourneyAnalysis>,
pub screenshot: Option<ViewportScreenshot>,
}
#[derive(Debug, Clone)]
pub struct DualViewportResults {
pub desktop: ViewportAuditData,
pub mobile: ViewportAuditData,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewportScoreSet {
pub accessibility: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance: Option<u32>,
pub overall: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewportScores {
pub desktop: ViewportScoreSet,
pub mobile: ViewportScoreSet,
pub weighted_overall: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditReport {
pub url: String,
pub wcag_level: WcagLevel,
pub timestamp: DateTime<Utc>,
pub wcag_results: WcagResults,
pub score: f32,
pub grade: String,
pub certificate: String,
pub statistics: ViolationStatistics,
pub nodes_analyzed: usize,
pub duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub performance: Option<PerformanceResults>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seo: Option<SeoAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub security: Option<SecurityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mobile: Option<MobileFriendliness>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub budget_violations: Vec<crate::audit::budget::BudgetViolation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ux: Option<UxAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub journey: Option<crate::journey::JourneyAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dark_mode: Option<DarkModeAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_quality: Option<crate::source_quality::SourceQualityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ai_visibility: Option<crate::ai_visibility::AiVisibilityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_visibility: Option<crate::content_visibility::ContentVisibilityAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_stack: Option<crate::tech_stack::TechStackAnalysis>,
#[serde(skip)]
pub page_screenshots: Option<PageScreenshots>,
#[serde(skip)]
pub dual_viewport: Option<DualViewportResults>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport_scores: Option<ViewportScores>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub throttled_performance: Vec<ThrottledPerfResult>,
#[serde(skip_serializing_if = "Option::is_none")]
pub patterns: Option<crate::patterns::PatternAnalysis>,
#[serde(default)]
pub screenshot_status: ScreenshotStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub best_practices: Option<crate::best_practices::BestPracticesAnalysis>,
#[serde(default)]
pub consent_banner_detected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub consent_banner_cmp: Option<String>,
#[serde(default)]
pub consent_banner_dismissed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub accessibility_journey: Option<crate::audit::normalized::AccessibilityJourney>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub interactive_findings: Vec<crate::audit::normalized::InteractiveFinding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub advisory_findings: Vec<crate::audit::normalized::AdvisoryFinding>,
#[serde(skip)]
pub screen_reader_audit: Option<crate::screen_reader::SrAuditReport>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceResults {
pub vitals: WebVitals,
pub score: PerformanceScore,
#[serde(skip_serializing_if = "Option::is_none")]
pub render_blocking: Option<RenderBlockingAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_weight: Option<ContentWeight>,
#[serde(skip_serializing_if = "Option::is_none")]
pub third_party: Option<ThirdPartyAttribution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub critical_chain: Option<CriticalChain>,
#[serde(skip_serializing_if = "Option::is_none")]
pub minification: Option<MinificationAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub animations: Option<AnimationAnalysis>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coverage: Option<CoverageAnalysis>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub measurement_warnings: Vec<String>,
}
impl AuditReport {
pub fn new(
url: String,
wcag_level: WcagLevel,
wcag_results: WcagResults,
duration_ms: u64,
) -> Self {
let score = AccessibilityScorer::calculate_score(&wcag_results.violations);
let grade = AccessibilityScorer::calculate_grade(score).to_string();
let certificate = AccessibilityScorer::calculate_certificate(score).to_string();
let statistics = AccessibilityScorer::calculate_statistics(&wcag_results.violations);
let nodes_analyzed = wcag_results.nodes_checked;
Self {
url,
wcag_level,
timestamp: Utc::now(),
wcag_results,
score,
grade,
certificate,
statistics,
nodes_analyzed,
duration_ms,
performance: None,
seo: None,
security: None,
mobile: None,
ux: None,
journey: None,
budget_violations: Vec::new(),
dark_mode: None,
source_quality: None,
ai_visibility: None,
content_visibility: None,
tech_stack: None,
page_screenshots: None,
dual_viewport: None,
viewport_scores: None,
throttled_performance: Vec::new(),
patterns: None,
screenshot_status: ScreenshotStatus::NotRequested,
best_practices: None,
consent_banner_detected: false,
consent_banner_cmp: None,
consent_banner_dismissed: false,
accessibility_journey: None,
interactive_findings: Vec::new(),
advisory_findings: Vec::new(),
screen_reader_audit: None,
}
}
pub fn with_performance(mut self, performance: PerformanceResults) -> Self {
self.performance = Some(performance);
self
}
pub fn with_patterns(mut self, patterns: crate::patterns::PatternAnalysis) -> Self {
self.patterns = Some(patterns);
self
}
pub fn with_seo(mut self, seo: SeoAnalysis) -> Self {
self.seo = Some(seo);
self
}
pub fn with_security(mut self, security: SecurityAnalysis) -> Self {
self.security = Some(security);
self
}
pub fn with_mobile(mut self, mobile: MobileFriendliness) -> Self {
self.mobile = Some(mobile);
self
}
pub fn with_ux(mut self, ux: UxAnalysis) -> Self {
self.ux = Some(ux);
self
}
pub fn with_journey(mut self, journey: crate::journey::JourneyAnalysis) -> Self {
self.journey = Some(journey);
self
}
pub fn with_dark_mode(mut self, dark_mode: DarkModeAnalysis) -> Self {
self.dark_mode = Some(dark_mode);
self
}
pub fn with_tech_stack(mut self, tech_stack: crate::tech_stack::TechStackAnalysis) -> Self {
self.tech_stack = Some(tech_stack);
self
}
pub fn with_best_practices(mut self, bp: crate::best_practices::BestPracticesAnalysis) -> Self {
self.best_practices = Some(bp);
self
}
pub fn violation_count(&self) -> usize {
self.wcag_results.violations.len()
}
pub fn passed(&self) -> bool {
self.score >= 70.0
&& !self
.wcag_results
.violations
.iter()
.any(|v| v.severity == crate::wcag::Severity::Critical)
}
pub fn overall_score(&self) -> u32 {
if let Some(ref vs) = self.viewport_scores {
let mut weighted = vs.weighted_overall as f64 * 90.0;
let mut total = 90.0;
if let Some(ref security) = self.security {
weighted += security.score as f64 * 10.0;
total += 10.0;
}
return (weighted / total).round() as u32;
}
let mut weighted_sum = self.score as f64 * 40.0;
let mut total_weight = 40.0;
if let Some(ref perf) = self.performance {
weighted_sum += perf.score.overall as f64 * 20.0;
total_weight += 20.0;
}
if let Some(ref seo) = self.seo {
weighted_sum += seo.score as f64 * 20.0;
total_weight += 20.0;
}
if let Some(ref security) = self.security {
weighted_sum += security.score as f64 * 10.0;
total_weight += 10.0;
}
if let Some(ref mobile) = self.mobile {
weighted_sum += mobile.score as f64 * 10.0;
total_weight += 10.0;
}
(weighted_sum / total_weight).round() as u32
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchReport {
pub reports: Vec<AuditReport>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<BatchError>,
pub summary: BatchSummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub crawl_diagnostics: Option<CrawlDiagnostics>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub consistency: Option<crate::audit::batch_consistency::BatchConsistencyAnalysis>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub sample: Option<SampleMetadata>,
pub total_duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SampleMetadata {
pub source: String,
pub total_discovered: usize,
pub audited: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_limit: Option<usize>,
pub selection: String,
pub is_sample: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BrokenLinkSeverity {
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedirectChain {
pub source_url: String,
pub target_url: String,
pub final_url: String,
pub hops: u8,
pub is_external: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrawlDiagnostics {
pub seed_url: String,
pub discovered_urls: usize,
pub checked_internal_links: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub broken_internal_links: Vec<BrokenLink>,
pub checked_external_links: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub broken_external_links: Vec<BrokenLink>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub redirect_chains: Vec<RedirectChain>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrokenLink {
pub source_url: String,
pub target_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub status_code: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub is_external: bool,
#[serde(default)]
pub redirect_hops: u8,
pub severity: BrokenLinkSeverity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchError {
pub url: String,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurringRule {
pub rule_id: String,
pub title: String,
pub wcag_criterion: String,
pub wcag_level: String,
pub severity: crate::taxonomy::Severity,
pub affected_pages: usize,
pub total_occurrences: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchSummary {
pub total_urls: usize,
pub passed: usize,
pub failed: usize,
pub average_score: f64,
pub total_violations: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub top_recurring_rules: Vec<RecurringRule>,
#[serde(default)]
pub violated_rule_count: usize,
#[serde(default)]
pub legal_flags: usize,
#[serde(default)]
pub risk: crate::audit::normalized::RiskLevel,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub verdict_key: String,
}
pub fn compute_recurring_rules(
reports: &[crate::audit::normalized::NormalizedReport],
) -> (Vec<RecurringRule>, usize) {
use std::collections::{HashMap, HashSet};
let violated_rule_count = reports
.iter()
.flat_map(|r| r.findings.iter().map(|f| f.rule_id.as_str()))
.collect::<HashSet<_>>()
.len();
struct Acc {
title: String,
wcag_criterion: String,
wcag_level: String,
severity: crate::taxonomy::Severity,
affected_pages: usize,
total_occurrences: usize,
}
let mut by_rule: HashMap<String, Acc> = HashMap::new();
for report in reports {
for f in report.findings.iter().filter(|f| f.category == "wcag") {
let entry = by_rule.entry(f.rule_id.clone()).or_insert(Acc {
title: f.title.clone(),
wcag_criterion: f.wcag_criterion.clone(),
wcag_level: f.wcag_level.clone(),
severity: f.severity,
affected_pages: 0,
total_occurrences: 0,
});
entry.affected_pages += 1;
entry.total_occurrences += f.occurrence_count;
}
}
let mut rules: Vec<RecurringRule> = by_rule
.into_iter()
.map(|(rule_id, a)| RecurringRule {
rule_id,
title: a.title,
wcag_criterion: a.wcag_criterion,
wcag_level: a.wcag_level,
severity: a.severity,
affected_pages: a.affected_pages,
total_occurrences: a.total_occurrences,
})
.collect();
rules.sort_by(|a, b| {
b.affected_pages
.cmp(&a.affected_pages)
.then_with(|| b.total_occurrences.cmp(&a.total_occurrences))
.then_with(|| b.severity.cmp(&a.severity))
});
rules.truncate(10);
(rules, violated_rule_count)
}
fn compute_legal_flags(reports: &[crate::audit::normalized::NormalizedReport]) -> usize {
use crate::taxonomy::Severity;
use std::collections::HashSet;
reports
.iter()
.flat_map(|r| r.findings.iter())
.filter(|f| {
f.wcag_level == "A" && matches!(f.severity, Severity::Critical | Severity::High)
})
.map(|f| f.rule_id.as_str())
.collect::<HashSet<_>>()
.len()
}
pub fn compute_worst_risk(
reports: &[crate::audit::normalized::NormalizedReport],
) -> crate::audit::normalized::RiskLevel {
use crate::audit::normalized::RiskLevel;
use std::collections::HashMap;
let page_count = reports.len().max(1);
let mut counts: HashMap<RiskLevel, usize> = HashMap::new();
for r in reports {
*counts.entry(r.risk.level).or_insert(0) += 1;
}
[RiskLevel::Critical, RiskLevel::High, RiskLevel::Medium]
.iter()
.copied()
.find(|&lvl| {
let n = *counts.get(&lvl).unwrap_or(&0);
n > 0 && (lvl == RiskLevel::Critical || n * 5 >= page_count)
})
.unwrap_or(RiskLevel::Low)
}
impl BatchReport {
pub fn from_reports(
reports: Vec<AuditReport>,
errors: Vec<BatchError>,
total_duration_ms: u64,
) -> Self {
let total_urls = reports.len();
let normalized_reports: Vec<crate::audit::normalized::NormalizedReport> = reports
.iter()
.map(|r| crate::audit::normalized::normalize(r).normalized)
.collect();
let passed = normalized_reports
.iter()
.filter(|r| {
let no_legal_flags = !r.findings.iter().any(|f| {
f.wcag_level == "A"
&& matches!(
f.severity,
crate::taxonomy::Severity::Critical | crate::taxonomy::Severity::High,
)
});
r.score >= 80 && r.severity_counts.critical == 0 && no_legal_flags
})
.count();
let failed = total_urls - passed;
let average_score = if total_urls > 0 {
normalized_reports
.iter()
.map(|r| r.score as f64)
.sum::<f64>()
/ total_urls as f64
} else {
0.0
};
let total_violations = normalized_reports
.iter()
.flat_map(|r| r.findings.iter())
.map(|f| f.occurrence_count)
.sum();
let (top_recurring_rules, violated_rule_count) =
compute_recurring_rules(&normalized_reports);
let legal_flags = compute_legal_flags(&normalized_reports);
let risk = compute_worst_risk(&normalized_reports);
let verdict_key = {
let s = average_score.round() as u32;
if s >= 90 {
"verdict-batch-excellent"
} else if s >= 70 {
"verdict-batch-solid"
} else if s >= 50 {
"verdict-batch-deficient"
} else {
"verdict-batch-critical"
}
.to_string()
};
let mut result = Self {
reports,
errors,
summary: BatchSummary {
total_urls,
passed,
failed,
average_score,
total_violations,
top_recurring_rules,
violated_rule_count,
legal_flags,
risk,
verdict_key,
},
crawl_diagnostics: None,
consistency: None,
sample: None,
total_duration_ms,
};
result.consistency = crate::audit::batch_consistency::analyze(&result);
result
}
pub fn with_crawl_diagnostics(mut self, diagnostics: CrawlDiagnostics) -> Self {
self.crawl_diagnostics = Some(diagnostics);
self
}
pub fn with_sample(mut self, sample: SampleMetadata) -> Self {
self.sample = Some(sample);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wcag::WcagResults;
#[test]
fn test_audit_report_new() {
let results = WcagResults::new();
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
results,
500,
);
assert_eq!(report.url, "https://example.com");
assert_eq!(report.score, 100.0); assert_eq!(report.duration_ms, 500);
assert!(report.performance.is_none());
assert!(report.seo.is_none());
assert!(report.security.is_none());
assert!(report.mobile.is_none());
}
#[test]
fn test_batch_report() {
let reports = vec![
AuditReport::new(
"https://a.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
),
AuditReport::new(
"https://b.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
200,
),
];
let batch = BatchReport::from_reports(reports, vec![], 300);
assert_eq!(batch.summary.total_urls, 2);
assert_eq!(batch.summary.passed, 2);
assert_eq!(batch.summary.average_score, 100.0);
}
#[test]
fn test_passed_with_perfect_score() {
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
assert!(report.passed());
}
#[test]
fn test_passed_with_critical_violation() {
let mut results = WcagResults::new();
results.add_violation(crate::wcag::Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
crate::wcag::Severity::Critical,
"Missing alt",
"node-1",
));
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
results,
100,
);
assert!(!report.passed());
}
#[test]
fn test_overall_score_wcag_only() {
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
assert_eq!(report.overall_score(), 100);
}
#[test]
fn test_overall_score_weighted() {
let mut report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
report.security = Some(crate::security::SecurityAnalysis {
score: 50,
grade: "D".to_string(),
headers: Default::default(),
ssl: Default::default(),
issues: vec![],
recommendations: vec![],
protection: Default::default(),
});
assert_eq!(report.overall_score(), 90);
}
#[test]
fn test_with_builder_methods() {
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
WcagResults::new(),
100,
);
let report = report.with_security(crate::security::SecurityAnalysis {
score: 80,
grade: "A".to_string(),
headers: Default::default(),
ssl: Default::default(),
issues: vec![],
recommendations: vec![],
protection: Default::default(),
});
assert!(report.security.is_some());
assert_eq!(report.security.as_ref().unwrap().score, 80);
}
#[test]
fn test_violation_count() {
let mut results = WcagResults::new();
results.add_violation(crate::wcag::Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
crate::wcag::Severity::High,
"Missing alt",
"node-1",
));
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
results,
100,
);
assert_eq!(report.violation_count(), 1);
}
#[test]
fn batch_summary_has_recurring_rules_and_risk() {
let reports = vec![
AuditReport::new(
"https://a.com".into(),
WcagLevel::AA,
WcagResults::new(),
100,
),
AuditReport::new(
"https://b.com".into(),
WcagLevel::AA,
WcagResults::new(),
100,
),
];
let batch = BatchReport::from_reports(reports, vec![], 0);
assert!(batch.summary.top_recurring_rules.is_empty());
assert_eq!(batch.summary.violated_rule_count, 0);
assert_eq!(batch.summary.legal_flags, 0);
assert_eq!(batch.summary.risk, crate::audit::normalized::RiskLevel::Low);
}
#[test]
fn compute_recurring_rules_aggregates_across_pages() {
use crate::audit::normalized::normalize;
use crate::wcag::Violation;
let mut results1 = WcagResults::new();
results1.add_violation(Violation::new(
"1.1.1",
"Alt",
WcagLevel::A,
crate::wcag::Severity::High,
"Missing alt",
"n1",
));
let mut results2 = WcagResults::new();
results2.add_violation(Violation::new(
"1.1.1",
"Alt",
WcagLevel::A,
crate::wcag::Severity::High,
"Missing alt",
"n2",
));
let r1 = normalize(&AuditReport::new(
"https://a.com".into(),
WcagLevel::AA,
results1,
100,
));
let r2 = normalize(&AuditReport::new(
"https://b.com".into(),
WcagLevel::AA,
results2,
100,
));
let (rules, violated_count) = compute_recurring_rules(&[r1.normalized, r2.normalized]);
assert_eq!(violated_count, 1, "one distinct rule fired");
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].affected_pages, 2, "rule appeared on both pages");
assert_eq!(rules[0].total_occurrences, 2);
}
#[test]
fn compute_worst_risk_critical_if_any_critical_page() {
use crate::audit::normalized::{normalize, RiskLevel};
use crate::wcag::Violation;
let empty = normalize(&AuditReport::new(
"https://a.com".into(),
WcagLevel::AA,
WcagResults::new(),
100,
));
let mut results = WcagResults::new();
for i in 0..3 {
results.add_violation(Violation::new(
"1.1.1",
"Alt",
WcagLevel::A,
crate::wcag::Severity::Critical,
"missing",
format!("n{i}"),
));
}
let risky = normalize(&AuditReport::new(
"https://b.com".into(),
WcagLevel::AA,
results,
50,
));
let risk = compute_worst_risk(&[empty.normalized, risky.normalized]);
assert!(
matches!(
risk,
RiskLevel::Critical | RiskLevel::High | RiskLevel::Medium
),
"batch risk must be elevated when one page has critical issues, got {:?}",
risk
);
}
}