use renderreport::components::advanced::{
ChecklistPanel, ChecklistRow, DevicePreview, DiagnosisPanel, DiagnosisRow, Divider, List,
MetricStrip, MetricStripItem, PageBreak, RecommendationCard, SectionHeaderSplit,
};
use renderreport::components::charts::{Chart, ChartType};
use renderreport::components::text::Label;
use renderreport::components::{AuditTable, CardDashboard, DashboardCard, Finding, TableColumn};
use renderreport::prelude::*;
use super::design;
use super::problem_profile::{
build_overall_leverage_note, build_problem_concentration_note, render_problem_profile,
};
use super::appendix::build_cli_snapshot_table;
use super::bik_guide::render_bik_guide_annex;
use super::detail_modules::{
mobile_category_label, render_a11y_journey_findings, render_ai_transparency,
render_ai_visibility, render_best_practices, render_budget_violations, render_commerce,
render_content_visibility, render_dark_mode, render_design_quality, render_html_conform,
render_journey, render_mobile, render_network_dns, render_performance,
render_screen_reader_section, render_search_experience, render_security, render_seo,
render_source_quality, render_tech_stack, render_ux, score_band_label,
};
use super::diagnosis::render_diagnosis_section;
use super::en301549::render_en301549_annex;
use super::findings::render_finding_technical;
use super::helpers::{
manual_recheck_instruction, map_severity, priority_label_i18n, role_label_i18n,
};
use super::wcag_coverage::render_wcag_coverage_section;
use crate::audit::AuditReport;
use crate::cli::{AnnexKind, ReportLevel};
use crate::i18n::I18n;
use crate::output::module::active_report_modules;
use crate::output::report_model::*;
pub(super) fn render_part_divider(
mut builder: renderreport::engine::ReportBuilder,
part_num: u8,
title: &str,
intro: &str,
audience_title: &str,
audience_body: &str,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let eyebrow = if en {
if part_num == 3 {
"PART 3 OF 3 · TECHNICAL APPENDIX".to_string()
} else {
format!("PART {} OF 3", part_num)
}
} else if part_num == 3 {
"TEIL 3 VON 3 · TECHNISCHER ANHANG".to_string()
} else {
format!("TEIL {} VON 3", part_num)
};
if part_num > 1 {
builder = builder.add_component(PageBreak::new());
}
builder = builder
.add_component(
Label::new(format!("{:02}", part_num))
.with_size("72pt")
.bold()
.with_color(design::tokens::MUTED),
)
.add_component(
SectionHeaderSplit::new(title, intro)
.with_eyebrow(eyebrow)
.with_level(1),
)
.add_component(
Label::new(format!("{}: {}", audience_title, audience_body))
.with_size("10.5pt")
.with_color(design::tokens::NEUTRAL),
);
if part_num == 3 {
builder = builder
.add_component(Divider::thick().with_color(design::tokens::MUTED))
.add_component(PageBreak::new());
}
builder
}
fn build_severity_counter_strip(vm: &ReportViewModel, i18n: &I18n) -> MetricStrip {
let en = i18n.locale() == "en";
let count_accent = |n: u32, hue: &'static str| {
if n > 0 {
hue
} else {
design::tokens::SUCCESS
}
};
let items = vec![
MetricStripItem::new(
if en {
"WCAG occurrences"
} else {
"WCAG-Vorkommen"
},
vm.severity.total.to_string(),
)
.with_accent(design::tokens::INK),
MetricStripItem::new(
if en { "Critical" } else { "Kritisch" },
vm.severity.critical.to_string(),
)
.with_accent(count_accent(vm.severity.critical, design::tokens::DANGER)),
MetricStripItem::new(
if en { "High" } else { "Hoch" },
vm.severity.high.to_string(),
)
.with_accent(count_accent(vm.severity.high, design::tokens::DANGER)),
MetricStripItem::new(
if en { "Medium" } else { "Mittel" },
vm.severity.medium.to_string(),
)
.with_accent(count_accent(vm.severity.medium, design::tokens::WARN_DEEP)),
];
MetricStrip::new(items).compact()
}
fn module_dashboard_card(module: &ModuleScore, i18n: &I18n) -> DashboardCard {
DashboardCard {
name: super::helpers::module_name_with_taxonomy_suffix(
&module.name,
&module.measurement_type,
i18n,
),
score: module.score,
interpretation: if module.interpretation.is_empty() {
format!("{}/100", module.score)
} else {
module.interpretation.clone()
},
good_threshold: 75,
warn_threshold: 40,
}
}
fn render_module_split_dashboards(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let indicator_modules: Vec<&ModuleScore> = vm
.modules
.dashboard
.iter()
.filter(|m| m.measurement_type != "measured")
.collect();
let strong: Vec<DashboardCard> = indicator_modules
.iter()
.filter(|m| m.score >= 75)
.map(|m| module_dashboard_card(m, i18n))
.collect();
let weak: Vec<DashboardCard> = indicator_modules
.iter()
.filter(|m| m.score < 75)
.map(|m| module_dashboard_card(m, i18n))
.collect();
if !strong.is_empty() {
builder = builder.add_component(CardDashboard::new(strong).with_title(if en {
"What works particularly well"
} else {
"Was besonders gut funktioniert"
}));
}
if !weak.is_empty() {
builder = builder.add_component(CardDashboard::new(weak).with_title(if en {
"Where optimization pays off"
} else {
"Wo sich Optimierung lohnt"
}));
} else if !indicator_modules.is_empty() {
let risk_gated = matches!(
vm.summary.certificate.as_str(),
"EINGESCHRÄNKT" | "NICHT BESTANDEN"
);
let message = if risk_gated {
if en {
"The additional indicator modules (UX, Journey, …) are in good shape, but operability/legal risk elsewhere in this report still requires action — see Management Summary."
} else {
"Die zusätzlichen Indikator-Module (UX, Journey, …) sind in gutem Zustand, dennoch besteht an anderer Stelle in diesem Report Bedienbarkeits- oder rechtliches Risiko, das Handeln erfordert — siehe Management-Zusammenfassung."
}
} else if en {
"The additional indicator modules (UX, Journey, …) are in good shape — no module needs prioritized optimization."
} else {
"Die zusätzlichen Indikator-Module (UX, Journey, …) sind in gutem Zustand — kein Modul erfordert vorrangige Optimierung."
};
builder = builder.add_component(Label::new(message).with_size("10.5pt").with_color(
if risk_gated {
design::tokens::WARN_DEEP
} else {
design::tokens::SUCCESS
},
));
}
builder
}
struct DimensionRow {
label: String,
description: String,
status: &'static str,
}
fn render_overall_rating(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
use crate::audit::verdict::Verdict;
let en = i18n.locale() == "en";
let rating = super::cover::certificate_label_localized(&vm.summary.certificate, i18n.locale());
let verdict_word = match (vm.summary.ci_verdict, en) {
(Verdict::Pass, true) => "passed",
(Verdict::Pass, false) => "bestanden",
(Verdict::Warn, true) => "passed with reservations",
(Verdict::Warn, false) => "mit Vorbehalt bestanden",
(Verdict::Fail, true) => "not passed",
(Verdict::Fail, false) => "nicht bestanden",
};
let mut body = if en {
format!("{rating} — automated check {verdict_word}.")
} else {
format!("{rating} — automatisierte Prüfung {verdict_word}.")
};
let reason_kinds: Vec<_> = vm
.summary
.ci_verdict_reasons
.iter()
.filter(|kind| {
!(vm.summary.run_is_partial
&& **kind == crate::audit::verdict::VerdictReasonKind::AuditQualityNotComplete)
})
.collect();
if !reason_kinds.is_empty() {
let reasons = reason_kinds
.iter()
.map(|kind| kind.text(en))
.collect::<Vec<_>>()
.join(", ");
body.push(' ');
body.push_str(&if en {
format!("Reasons: {reasons}.")
} else {
format!("Gründe: {reasons}.")
});
}
if vm.summary.run_is_partial {
body.push(' ');
body.push_str(if en {
"This run did not complete in full, so the rating is provisional and describes only what was measured successfully."
} else {
"Dieser Lauf ist nicht vollständig durchgelaufen; die Einstufung ist daher vorläufig und beschreibt nur den erfolgreich gemessenen Umfang."
});
}
let title = if en {
"Overall rating"
} else {
"Gesamteinstufung"
};
let callout = match vm.summary.ci_verdict {
Verdict::Fail => Callout::warning(&body),
Verdict::Warn => Callout::info(&body),
Verdict::Pass => Callout::success(&body),
};
builder = builder.add_component(callout.with_title(title));
builder.add_component(
Label::new(if en {
"The rating is a band label for the accessibility score within the automated scope, not a conformance certificate."
} else {
"Die Einstufung ist ein Bandlabel für den Barrierefreiheits-Wert im automatisierten Prüfumfang, kein Konformitätsnachweis."
})
.with_size("8.5pt")
.with_color(design::tokens::MUTED),
)
}
pub(super) fn render_risks_and_strengths(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let dimensions: Vec<DimensionRow> = vm
.management_risks
.iter()
.filter(|kind| kind.tier() != crate::audit::management_risk::RiskTier::Unknown)
.map(|kind| DimensionRow {
label: kind.dimension(en),
description: kind.rationale(en),
status: kind.tier().status(),
})
.collect();
let (mut risks, strengths): (Vec<DimensionRow>, Vec<DimensionRow>) =
dimensions.into_iter().partition(|d| d.status != "good");
risks.sort_by_key(|d| if d.status == "bad" { 0u8 } else { 1u8 });
if risks.is_empty() {
let (automated, total) = crate::wcag::coverage::coverage_stats();
let empty_state = if en {
format!(
"No priority risks identified within the automated audit scope. This result applies to the automated audit scope only ({automated} of about {total} testable WCAG 2.1 AA criteria); criteria requiring manual review are listed in the appendix."
)
} else {
format!(
"Keine prioritären Risiken im automatisierten Prüfumfang erkannt. Diese Einschätzung bezieht sich ausschließlich auf den automatisierten Prüfumfang ({automated} von ca. {total} testbaren WCAG-2.1-AA-Kriterien); Kriterien mit manuellem Prüfbedarf sind im Anhang aufgeführt."
)
};
builder = builder.add_component(Callout::success(&empty_state).with_title(if en {
"Key Risks"
} else {
"Wichtigste Risiken"
}));
} else {
let title = if en {
format!("Key Risks ({} identified)", risks.len())
} else {
format!("Wichtigste Risiken ({} erkannt)", risks.len())
};
let rows: Vec<ChecklistRow> = risks
.into_iter()
.map(|d| ChecklistRow::new(d.label, d.description).with_status(d.status))
.collect();
builder = builder.add_component(ChecklistPanel::new(rows).with_title(title));
}
if !strengths.is_empty() {
let rows: Vec<ChecklistRow> = strengths
.into_iter()
.map(|d| ChecklistRow::new(d.label, d.description).with_status("good"))
.collect();
builder = builder.add_component(ChecklistPanel::new(rows).with_title(if en {
"Strengths within the audited scope"
} else {
"Stärken im geprüften Umfang"
}));
}
builder
}
struct TopMeasure {
module: String,
title: String,
detail: String,
}
impl TopMeasure {
fn render(&self, index: usize, extra: usize, en: bool) -> String {
let more = match (extra, en) {
(0, _) => String::new(),
(n, true) => format!(" (+{n} more of the same kind)"),
(n, false) => format!(" (+{n} weitere gleicher Art)"),
};
if self.title.is_empty() {
format!("{}. {} — {}{more}", index, self.module, self.detail)
} else {
format!(
"{}. {} — {}: {}{more}",
index, self.module, self.title, self.detail
)
}
}
}
fn cross_module_measures(vm: &ReportViewModel, i18n: &I18n) -> Vec<(TopMeasure, usize)> {
use crate::wcag::Severity;
let details = &vm.module_details;
let module_label = |key: &str| -> String {
let translated = i18n.t(&format!("module-{key}"));
if translated == format!("module-{key}") {
key.to_string()
} else {
translated
}
};
let cost_of = |name: &str| -> u32 {
vm.modules
.module_scores
.iter()
.find(|m| m.name.eq_ignore_ascii_case(name) && m.contributes_to_overall)
.map(|m| (100u32.saturating_sub(m.score)) * m.weight_pct)
.unwrap_or(0)
};
let severity_rank = |s: Severity| match s {
Severity::Critical => 0u8,
Severity::High => 1,
Severity::Medium => 2,
Severity::Low => 3,
};
let mut ranked: Vec<(u8, u32, TopMeasure)> = Vec::new();
if let Some(sec) = details.security.as_ref() {
let cost = cost_of("Security");
for (title, severity, message) in &sec.issues {
ranked.push((
severity_rank(*severity),
cost,
TopMeasure {
module: module_label("security"),
title: title.clone(),
detail: message.clone(),
},
));
}
}
if let Some(mobile) = details.mobile.as_ref() {
let cost = cost_of("Mobile");
for (category, severity, message) in &mobile.issues {
ranked.push((
severity_rank(*severity),
cost,
TopMeasure {
module: module_label("mobile"),
title: mobile_category_label(category, i18n),
detail: message.clone(),
},
));
}
}
if let Some(seo) = details.seo.as_ref() {
let cost = cost_of("SEO");
for (title, severity, message) in &seo.meta_issues {
ranked.push((
severity_rank(*severity),
cost,
TopMeasure {
module: module_label("seo"),
title: title.clone(),
detail: message.clone(),
},
));
}
for (_issue_type, message, _severity_label) in &seo.technical_issues {
ranked.push((
severity_rank(Severity::Medium),
cost,
TopMeasure {
module: module_label("seo"),
title: String::new(),
detail: message.clone(),
},
));
}
}
if let Some(perf) = details.performance.as_ref() {
let cost = cost_of("Performance");
for recommendation in &perf.recommendations {
ranked.push((
severity_rank(Severity::Medium),
cost,
TopMeasure {
module: module_label("performance"),
title: String::new(),
detail: recommendation.clone(),
},
));
}
}
ranked.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
let mut collapsed: Vec<(TopMeasure, usize)> = Vec::new();
for (_, _, measure) in ranked {
let duplicate = !measure.title.is_empty()
&& collapsed
.iter()
.any(|(kept, _)| kept.module == measure.module && kept.title == measure.title);
if duplicate {
if let Some(entry) = collapsed
.iter_mut()
.find(|(kept, _)| kept.module == measure.module && kept.title == measure.title)
{
entry.1 += 1;
}
} else {
collapsed.push((measure, 0));
}
}
collapsed
}
pub(super) fn build_top_measures_list(vm: &ReportViewModel, i18n: &I18n) -> List {
let en = i18n.locale() == "en";
let list_title = if en {
"5 Key Measures"
} else {
"Die 5 wichtigsten Maßnahmen"
};
let mut list = List::new().with_title(list_title);
let accessibility = module_label_accessibility(i18n);
let mut measures: Vec<(TopMeasure, usize)> = vm
.findings
.top_findings
.iter()
.take(5)
.map(|group| {
(
TopMeasure {
module: accessibility.clone(),
title: group.title.clone(),
detail: group.recommendation.clone(),
},
0,
)
})
.collect();
if measures.len() < 5 {
let missing = 5 - measures.len();
measures.extend(cross_module_measures(vm, i18n).into_iter().take(missing));
}
if measures.is_empty() {
let no_measures = if en {
"No urgent actions required."
} else {
"Keine dringenden Maßnahmen erforderlich."
};
list = list.add_item(no_measures);
return list;
}
for (idx, (measure, extra)) in measures.iter().enumerate() {
list = list.add_item(measure.render(idx + 1, *extra, en));
}
list
}
fn module_label_accessibility(i18n: &I18n) -> String {
let translated = i18n.t("module-accessibility");
if translated == "module-accessibility" {
"Accessibility".to_string()
} else {
translated
}
}
pub(super) fn audit_scope_line(en: bool) -> String {
if en {
"Audited: 1 URL · Desktop and Mobile · no website crawl".to_string()
} else {
"Geprüft: 1 URL · Desktop und Mobile · kein Website-Crawl".to_string()
}
}
pub(super) fn render_management_page(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let mgt_title = if en {
"Management Summary"
} else {
"Management-Sicht"
};
let mgt_subtitle = if en {
"Overall status, key risks, prioritized actions, and leverage at a glance."
} else {
"Gesamtstatus, Hauptrisiken, wichtigste Maßnahmen und Hebel auf einen Blick."
};
builder = builder.add_component(
SectionHeaderSplit::new(mgt_title, mgt_subtitle)
.with_eyebrow(if en {
"MANAGEMENT SUMMARY"
} else {
"MANAGEMENT-SUMMARY"
})
.with_level(1),
);
builder = builder.add_component(
Label::new(audit_scope_line(en))
.with_size("10.5pt")
.bold()
.with_color(design::tokens::NEUTRAL),
);
builder = builder.add_component(build_severity_counter_strip(vm, i18n));
builder = builder.add_component(
Label::new(if en {
"Occurrences = individual affected elements, not the number of distinct rules — see the appendix for the full breakdown."
} else {
"Vorkommen = einzelne betroffene Elemente, nicht die Anzahl unterschiedlicher Regeln — vollständige Aufschlüsselung im Anhang."
})
.with_size("8.5pt")
.with_color(design::tokens::MUTED),
);
let evidence = &vm.findings.evidence;
if evidence.warnings > 0 || evidence.manual_checks > 0 {
builder = builder.add_component(
Label::new(if en {
format!(
"Counted here are confirmed WCAG violations only. In addition: {} heuristic accessibility warning(s) and {} criterion/criteria requiring manual review — documented, but not scored.",
evidence.warnings, evidence.manual_checks
)
} else {
format!(
"Gezählt sind hier ausschließlich bestätigte WCAG-Verstöße. Hinzu kommen {} heuristische Barrierefreiheits-Warnung(en) und {} manuell zu prüfende(s) Kriterium/Kriterien — dokumentiert, aber nicht in den Score eingerechnet.",
evidence.warnings, evidence.manual_checks
)
})
.with_size("8.5pt")
.with_color(design::tokens::MUTED),
);
}
builder = builder.add_component(Callout::info(&vm.summary.verdict).with_title(if en {
"Overall Verdict"
} else {
"Gesamturteil"
}));
builder = render_overall_rating(builder, vm, i18n);
builder = render_problem_profile(builder, vm, i18n);
if let Some(note) = &vm.summary.audit_quality_note {
let callout = if vm.summary.audit_quality_severe {
Callout::warning(note).with_title(if en {
"Audit Quality"
} else {
"Audit-Qualität"
})
} else {
Callout::info(note).with_title(if en { "Coverage" } else { "Prüfabdeckung" })
};
builder = builder.add_component(callout);
}
if !vm.severity.has_issues {
let (automated, total) = crate::wcag::coverage::coverage_stats();
builder = builder.add_component(
Label::new(if en {
format!(
"This result applies to the automated audit scope only ({automated} of ~{total} testable WCAG 2.1 AA criteria); criteria requiring manual review are listed in the appendix."
)
} else {
format!(
"Diese Einschätzung bezieht sich ausschließlich auf den automatisierten Prüfumfang ({automated} von ca. {total} testbaren WCAG-2.1-AA-Kriterien); Kriterien mit manuellem Prüfbedarf sind im Anhang aufgeführt."
)
})
.with_size("9pt")
.with_color(design::tokens::MUTED),
);
}
let radar_data: Vec<(String, f64)> = vm
.modules
.dashboard
.iter()
.map(|m| {
let short = m
.name
.split([' ', '&'])
.next()
.unwrap_or(m.name.as_str())
.trim()
.to_string();
(short, m.score as f64)
})
.collect();
if radar_data.len() >= 3 {
builder = builder.add_component(
Chart::new(
if en {
"Quality profile"
} else {
"Qualitätsprofil"
},
ChartType::Radar,
)
.add_series("scores", radar_data),
);
}
builder = render_score_driver_table(builder, vm, i18n);
if let Some(note) = build_problem_concentration_note(vm, i18n) {
builder = builder.add_component(Label::new(note).with_size("10.5pt"));
}
builder = render_additional_score_drivers(builder, vm, i18n);
let en_label = if en {
"Accessibility"
} else {
"Barrierefreiheit"
};
let accessibility_entries: Vec<(String, u32)> = vm
.modules
.accessibility_subcategory_scores
.iter()
.map(|s| (s.subcategory_kind.label(en).to_string(), s.score))
.collect();
builder = render_subcategory_breakdown(builder, en_label, &accessibility_entries, i18n);
let security_label = if en { "Security" } else { "Sicherheit" };
let security_entries: Vec<(String, u32)> = vm
.modules
.security_category_scores
.iter()
.map(|s| (s.category_kind.label(en).to_string(), s.score))
.collect();
builder = render_subcategory_breakdown(builder, security_label, &security_entries, i18n);
builder = render_module_split_dashboards(builder, vm, i18n);
builder = render_risks_and_strengths(builder, vm, i18n);
builder = builder.add_component(build_top_measures_list(vm, i18n));
builder
}
fn render_score_driver_table(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let contributing: Vec<&crate::audit::normalized::ModuleScoreEntry> = vm
.modules
.module_scores
.iter()
.filter(|m| m.contributes_to_overall)
.collect();
let basis: u32 = contributing.iter().map(|m| m.weight_pct).sum();
builder = builder
.add_component(
ScoreCard::new(
if en {
"Combined technical score · 0–100"
} else {
"Kombinierter technischer Wert · 0–100"
},
vm.summary.overall_score,
)
.with_description(if en {
format!("Weighted across six modules, weight basis {basis}")
} else {
format!("Gewichtet über sechs Module, Gewichtsbasis {basis}")
})
.with_thresholds(75, 40),
)
.add_component(
Label::new(if en {
"A different question from the accessibility score on the cover, which is what this report assesses."
} else {
"Eine andere Frage als der Barrierefreiheits-Wert auf dem Deckblatt, um den es in diesem Bericht geht."
})
.with_size("10.5pt")
.with_color(design::tokens::NEUTRAL),
);
if contributing.len() < 2 {
return builder;
}
let weakest_name = contributing
.iter()
.filter(|m| m.score < 75)
.max_by_key(|m| (100 - m.score) * m.weight_pct)
.map(|m| m.name.clone());
let classify = |m: &crate::audit::normalized::ModuleScoreEntry| -> &'static str {
if Some(&m.name) == weakest_name.as_ref() {
if en {
"Biggest weak point"
} else {
"Größter Schwachpunkt"
}
} else if m.score < 75 {
if en {
"Significant risk driver"
} else {
"Erheblicher Risikotreiber"
}
} else if en {
"Stabilizes the combined technical score"
} else {
"Stabilisiert den kombinierten Wert"
}
};
let is_derivation = vm.summary.score_calculation_method != "viewport_weighted";
let mut table = AuditTable::new(vec![
TableColumn::new(if en { "Module" } else { "Modul" }).with_width("28%"),
TableColumn::new("Score").with_width("14%"),
TableColumn::new(if en { "Weight" } else { "Gewichtung" }).with_width("16%"),
TableColumn::new(if en { "Assessment" } else { "Einordnung" }).with_width("42%"),
])
.with_title(match (is_derivation, en) {
(true, true) => "Why the combined technical score is what it is",
(true, false) => "Warum der kombinierte technische Wert ist, was er ist",
(false, true) => "The modules that carry weight",
(false, false) => "Die gewichteten Module im Einzelnen",
});
for m in &contributing {
let key = format!("module-{}", m.name.to_lowercase().replace(' ', "-"));
let translated = i18n.t(&key);
let display_name = if translated == key {
m.name.clone()
} else {
translated
};
table = table.add_row(vec![
display_name,
format!("{} ({})", m.score, score_band_label(m.score, i18n)),
format!("{}%", m.weight_pct),
classify(m).to_string(),
]);
}
builder = builder.add_component(table);
builder = render_weight_basis_note(builder, vm, i18n);
render_overall_score_derivation(builder, vm, i18n)
}
fn render_weight_basis_note(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let basis: u32 = vm
.modules
.module_scores
.iter()
.filter(|m| m.contributes_to_overall)
.map(|m| m.weight_pct)
.sum();
if basis >= 100 {
return builder;
}
let contributing: std::collections::HashSet<&str> = vm
.modules
.module_scores
.iter()
.filter(|m| m.contributes_to_overall)
.map(|m| m.name.as_str())
.collect();
let missing: Vec<String> = crate::taxonomy::MODULE_WEIGHTS
.iter()
.filter(|(name, _)| !contributing.contains(name))
.map(|(name, weight)| format!("{name} ({weight} %)"))
.collect();
let mut note = if en {
format!(
"The combined technical score is normalised over the modules that were measured — a weight basis of {basis} of 100, not the full set."
)
} else {
format!(
"Der kombinierte technische Wert ist auf die gemessenen Module normiert — Gewichtsbasis {basis} von 100, nicht der volle Satz."
)
};
if !missing.is_empty() {
note.push(' ');
note.push_str(&if en {
format!("Not included: {}.", missing.join(", "))
} else {
format!("Nicht enthalten: {}.", missing.join(", "))
});
}
note.push(' ');
note.push_str(if en {
"A score from a different module set is not directly comparable."
} else {
"Ein Wert aus einem anderen Modulsatz ist damit nicht direkt vergleichbar."
});
builder = builder.add_component(Callout::info(¬e).with_title(if en {
"Weight basis"
} else {
"Gewichtsbasis"
}));
builder
}
fn render_overall_score_derivation(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let Some(sb) = vm.summary.score_breakdown.as_ref() else {
return builder;
};
let mut table = AuditTable::new(vec![
TableColumn::new(if en { "Step" } else { "Schritt" }).with_width("46%"),
TableColumn::new(if en { "Input" } else { "Eingang" }).with_width("34%"),
TableColumn::new(if en { "Result" } else { "Ergebnis" }).with_width("20%"),
])
.with_title(if en {
"How the combined technical score was calculated"
} else {
"Wie der kombinierte technische Wert zustande kommt"
});
table = table.add_row(vec![
if en {
"Viewport blend (mobile weighs more)".to_string()
} else {
"Viewport-Mischung (Mobile zählt mehr)".to_string()
},
format!(
"Desktop {} x {} % + Mobile {} x {} %",
sb.desktop_overall, sb.desktop_weight_pct, sb.mobile_overall, sb.mobile_weight_pct
),
sb.viewport_blended_overall.to_string(),
]);
match (sb.security_score, sb.security_weight_pct) {
(Some(security), Some(security_weight)) => {
table = table.add_row(vec![
if en {
"Security mixed in".to_string()
} else {
"Sicherheit eingemischt".to_string()
},
format!(
"{} x {} % + {} x {} %",
sb.viewport_blended_overall,
sb.viewport_blend_weight_pct,
security,
security_weight
),
vm.summary.overall_score.to_string(),
]);
}
_ => {
table = table.add_row(vec![
if en {
"Security not measured — blend carries the full weight".to_string()
} else {
"Sicherheit nicht gemessen — die Mischung trägt voll".to_string()
},
format!("{} x 100 %", sb.viewport_blended_overall),
vm.summary.overall_score.to_string(),
]);
}
}
builder = builder.add_component(table);
builder.add_component(
Label::new(if en {
"The module weights above rank what carries how much; this table is the calculation that produced the combined technical score."
} else {
"Die Modulgewichte oben zeigen, was wie stark zählt; diese Tabelle ist die Rechnung, aus der der kombinierte technische Wert entsteht."
})
.with_size("8.8pt")
.with_color(design::tokens::MUTED),
)
}
fn render_additional_score_drivers(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
if vm.summary.technical_overview.len() < 4 {
return builder;
}
let mut list = List::new().with_title(if en {
"Additional score drivers"
} else {
"Weitere Score-Treiber"
});
for item in &vm.summary.technical_overview[1..4] {
list = list.add_item(item);
}
builder = builder.add_component(list);
builder
}
fn render_subcategory_breakdown(
mut builder: renderreport::engine::ReportBuilder,
title: &str,
entries: &[(String, u32)],
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
if entries.is_empty() {
return builder;
}
const STABLE_CUTOFF: f32 = 60.0;
let mut weak: Vec<&(String, u32)> = entries
.iter()
.filter(|(_, score)| (*score as f32) < STABLE_CUTOFF)
.collect();
let mut stable: Vec<&(String, u32)> = entries
.iter()
.filter(|(_, score)| (*score as f32) >= STABLE_CUTOFF)
.collect();
if weak.is_empty() && stable.is_empty() {
return builder;
}
weak.sort_by_key(|(_, score)| *score);
stable.sort_by_key(|(_, score)| std::cmp::Reverse(*score));
let render_side = |items: &[&(String, u32)]| -> String {
items
.iter()
.map(|(name, score)| format!("{name} {score}"))
.collect::<Vec<_>>()
.join(" · ")
};
let body = if !weak.is_empty() && !stable.is_empty() {
if en {
format!(
"Critical weaknesses: {}\nStronger sub-areas within {title}: {}",
render_side(&weak),
render_side(&stable)
)
} else {
format!(
"Kritische Schwächen: {}\nStärkere Teilbereiche innerhalb der {title}: {}",
render_side(&weak),
render_side(&stable)
)
}
} else if !weak.is_empty() {
if en {
format!("Critical weaknesses: {}", render_side(&weak))
} else {
format!("Kritische Schwächen: {}", render_side(&weak))
}
} else if en {
format!(
"Stronger sub-areas within {title}: {}",
render_side(&stable)
)
} else {
format!(
"Stärkere Teilbereiche innerhalb der {title}: {}",
render_side(&stable)
)
};
builder = builder.add_component(Callout::info(body).with_title(title));
builder
}
pub(super) fn render_tech_details(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
report: &AuditReport,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
if !vm.modules.dashboard.is_empty() {
let diag_rows: Vec<DiagnosisRow> = vm
.modules
.dashboard
.iter()
.map(|module| {
let status = if module.score >= 80 {
"good"
} else if module.score >= 50 {
"warn"
} else {
"bad"
};
let display_name = super::helpers::module_name_with_taxonomy_suffix(
&module.name,
&module.measurement_type,
i18n,
);
DiagnosisRow::new(&display_name, format!("{}/100", module.score))
.with_status(status)
})
.collect();
builder = builder.add_component(
DiagnosisPanel::new(diag_rows).with_title(i18n.t("panel-modules-overview")),
);
}
if vm.severity.has_issues {
builder = render_diagnosis_section(builder, &vm.diagnosis, i18n);
}
let report_ts = report.timestamp.timestamp_nanos_opt().unwrap_or(0);
let mut evidence_seq: usize = 0;
let mut acronyms_expanded = false;
builder = render_findings_section(
builder,
vm,
en,
i18n,
report_ts,
&mut evidence_seq,
&mut acronyms_expanded,
);
if !report.interactive_findings.is_empty() {
builder = render_a11y_journey_findings(
builder,
&report.interactive_findings,
report.accessibility_journey.as_ref(),
i18n,
);
}
if let Some(sr) = report.screen_reader_audit.as_ref() {
builder = render_screen_reader_section(builder, sr, report.patterns.as_ref(), i18n);
}
builder
}
pub(super) fn render_appendix_full(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
report: &AuditReport,
findings: &[crate::audit::normalized::NormalizedFinding],
config: &ReportConfig,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let (app_title, app_intro) = if en {
(
"Appendix & Methodology",
"Technical scope, methodology, WCAG coverage, and the complete violations list.",
)
} else {
(
"Anhang & Methodik",
"Prüfumfang, Methodik, WCAG-Coverage und die vollständige Fundstellenliste.",
)
};
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(app_title, app_intro)
.with_eyebrow(if en { "APPENDIX" } else { "ANHANG" })
.with_level(1),
);
if vm.meta.report_level != ReportLevel::Executive {
builder = render_wcag_coverage_section(builder, report, i18n);
builder = render_manual_only_criteria_note(builder, i18n);
if config.annex == Some(AnnexKind::En301549) {
builder = render_en301549_annex(
builder,
findings,
&report.accessibility.wcag_results.rule_outcomes,
i18n,
);
}
if config.annex == Some(AnnexKind::Bik) {
let design_quality_findings: &[crate::design_quality::DesignQualityFinding] = report
.experience
.design_quality
.as_ref()
.map(|m| m.findings.as_slice())
.unwrap_or(&[]);
let seo_technical_issues: Vec<crate::seo::technical::TechnicalIssue> = report
.discoverability
.seo
.as_ref()
.map(|s| crate::seo::collect_technical_issues(&s.technical, en))
.unwrap_or_default();
let easy_language_detected = report
.patterns
.as_ref()
.is_some_and(|p| p.recognized.iter().any(|r| r.pattern == "EasyLanguage"));
let screen_reader_issues: &[crate::screen_reader::SrAuditIssue] = report
.screen_reader_audit
.as_ref()
.map(|sr| sr.issues.as_slice())
.unwrap_or(&[]);
let accessibility_assessments =
crate::audit::normalized::normalize_assessments(&report.accessibility.wcag_results);
builder = render_bik_guide_annex(
builder,
findings,
&report.interactive_findings,
screen_reader_issues,
design_quality_findings,
&seo_technical_issues,
easy_language_detected,
&accessibility_assessments,
i18n,
);
}
}
builder = render_assessment_and_execution_notes(builder, report, i18n);
let limitations_title = i18n.t("callout-limitations-title");
let limitations_text = format!("{}: {}", limitations_title, vm.methodology.limitations);
builder = builder.add_component(
Label::new(&limitations_text)
.with_size("10.5pt")
.with_color("#475569"),
);
let disclaimer_title = i18n.t("callout-note-title");
let disclaimer_text = format!("{}: {}", disclaimer_title, vm.methodology.disclaimer);
builder = builder.add_component(
Label::new(&disclaimer_text)
.with_size("10.5pt")
.with_color("#475569"),
);
builder = render_appendix_section(builder, vm, i18n);
builder
}
fn render_manual_only_criteria_note(
builder: renderreport::engine::ReportBuilder,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let rows = if en {
vec![
ChecklistRow::new(
"2.4.3 · Focus Order",
"Whether the technical focus order makes sense in context depends on the page's \
content and cannot be assessed automatically.",
),
ChecklistRow::new(
"3.3.1 · Error Identification",
"Whether an error message is actually understandable to people is a content \
question, not a structural check.",
),
ChecklistRow::new(
"1.4.4 / 1.4.10 · Resize Text & Reflow",
"Whether the page stays practically usable at 400% zoom goes beyond the \
technical reflow structure and requires manual review.",
),
ChecklistRow::new(
"1.1.1 · Non-text Content",
"Whether alt text correctly describes the image content can only be judged by \
a person; automated checks can only confirm that alt text is present at all.",
),
ChecklistRow::new(
"1.2.1 / 1.2.2 · Time-based Media",
"Whether a caption or transcript summary correctly reflects a video's content \
cannot be checked automatically.",
),
]
} else {
vec![
ChecklistRow::new(
"2.4.3 · Fokusreihenfolge",
"Ob die technische Fokusreihenfolge inhaltlich sinnvoll ist, hängt vom \
Seitenaufbau ab und lässt sich nicht automatisiert bewerten.",
),
ChecklistRow::new(
"3.3.1 · Fehlerkennzeichnung",
"Ob eine Fehlermeldung für Menschen tatsächlich verständlich ist, ist eine \
inhaltliche Frage und kein struktureller Check.",
),
ChecklistRow::new(
"1.4.4 / 1.4.10 · Textgröße & Reflow",
"Ob die Seite bei 400 % Zoom praktisch bedienbar bleibt, geht über die \
technische Reflow-Struktur hinaus und erfordert eine manuelle Prüfung.",
),
ChecklistRow::new(
"1.1.1 · Nicht-Text-Inhalte",
"Ob ein Alternativtext den Bildinhalt korrekt beschreibt, kann nur ein Mensch \
beurteilen; automatisiert prüfbar ist nur, ob überhaupt ein Alternativtext \
vorhanden ist.",
),
ChecklistRow::new(
"1.2.1 / 1.2.2 · Zeitbasierte Medien",
"Ob eine Untertitel- oder Transkript-Zusammenfassung den Videoinhalt inhaltlich \
korrekt wiedergibt, lässt sich nicht automatisiert prüfen.",
),
]
};
builder.add_component(ChecklistPanel::new(rows).with_title(if en {
"Structurally manual-only criteria"
} else {
"Strukturell nur manuell prüfbare Kriterien"
}))
}
fn render_assessment_and_execution_notes(
mut builder: renderreport::engine::ReportBuilder,
report: &AuditReport,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let wcag = &report.accessibility.wcag_results;
if !wcag.warnings.is_empty() || !wcag.not_testables.is_empty() {
let mut rows = Vec::new();
for finding in wcag.not_testables.iter().take(20) {
let mut recommendation = finding
.rule_id
.as_deref()
.and_then(crate::output::explanations::get_explanation)
.or_else(|| crate::output::explanations::get_explanation(&finding.rule))
.map(|explanation| explanation.recommendation_for(i18n.locale()).to_string())
.unwrap_or_else(|| {
if en {
"Verify this criterion manually on the rendered page.".to_string()
} else {
"Dieses Kriterium manuell an der gerenderten Seite prüfen.".to_string()
}
});
if let Some(instruction) = manual_recheck_instruction(&finding.rule, en) {
recommendation.push(' ');
recommendation.push_str(instruction);
}
rows.push(
ChecklistRow::new(
format!(
"{} · {}",
finding.rule,
if en {
"Manual review"
} else {
"Manuelle Prüfung"
}
),
recommendation,
)
.with_status("warn"),
);
}
for finding in wcag
.warnings
.iter()
.take(20usize.saturating_sub(rows.len()))
{
let mut text = finding
.rule_id
.as_deref()
.and_then(crate::output::explanations::get_explanation)
.or_else(|| crate::output::explanations::get_explanation(&finding.rule))
.map(|explanation| explanation.recommendation_for(i18n.locale()).to_string())
.unwrap_or_else(|| {
if en {
"Confirm this heuristic signal manually.".to_string()
} else {
"Dieses heuristische Signal manuell bestätigen.".to_string()
}
});
if let Some(instruction) = manual_recheck_instruction(&finding.rule, en) {
text.push(' ');
text.push_str(instruction);
}
rows.push(
ChecklistRow::new(
format!(
"{} · {}",
finding.rule,
if en { "Warning" } else { "Hinweis" }
),
text,
)
.with_status("warn"),
);
}
builder = builder.add_component(ChecklistPanel::new(rows).with_title(if en {
"Manual review and heuristic signals"
} else {
"Manuelle Prüfpunkte und heuristische Hinweise"
}));
}
if let Some(journey) = report.accessibility_journey.as_ref() {
let execution = &journey.execution;
let text = if en {
format!(
"Interactive coverage: {} of {} attempted journeys completed; {} failed, {} skipped{}.",
execution.completed,
execution.attempted,
execution.failed,
execution.skipped,
if execution.budget_exhausted { "; budget exhausted" } else { "" }
)
} else {
format!(
"Interaktive Abdeckung: {} von {} versuchten Journeys abgeschlossen; {} fehlgeschlagen, {} übersprungen{}.",
execution.completed,
execution.attempted,
execution.failed,
execution.skipped,
if execution.budget_exhausted { "; Budget ausgeschöpft" } else { "" }
)
};
builder = builder.add_component(Label::new(text).with_size("10.5pt").with_color("#475569"));
}
builder
}
fn render_findings_section(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
en: bool,
i18n: &I18n,
report_ts: i64,
evidence_seq: &mut usize,
acronyms_expanded: &mut bool,
) -> renderreport::engine::ReportBuilder {
if vm.severity.has_issues {
let (findings_title, findings_intro) = if en {
(
"Classification of Findings",
"All technical findings, categorized by systemic template relevance and individual page occurrences.",
)
} else {
(
"Klassifizierung der Befunde",
"Alle technischen Befunde, getrennt nach systemischen Komponentenfehlern und Einzelfällen.",
)
};
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(findings_title, findings_intro)
.with_eyebrow(if en { "FINDINGS" } else { "BEFUNDE" })
.with_level(2),
);
let all_findings = &vm.findings.all_findings;
let systemic_mandatory: Vec<&FindingGroup> = all_findings
.iter()
.filter(|f| f.is_component_issue && f.criticality_tier == CriticalityTier::Mandatory)
.collect();
let systemic_optimization: Vec<&FindingGroup> = all_findings
.iter()
.filter(|f| f.is_component_issue && f.criticality_tier == CriticalityTier::Optimization)
.collect();
let local_mandatory: Vec<&FindingGroup> = all_findings
.iter()
.filter(|f| !f.is_component_issue && f.criticality_tier == CriticalityTier::Mandatory)
.collect();
let local_optimization: Vec<&FindingGroup> = all_findings
.iter()
.filter(|f| {
!f.is_component_issue && f.criticality_tier == CriticalityTier::Optimization
})
.collect();
builder = builder.add_component(
MetricStrip::new(vec![
MetricStripItem::new(
if en {
"Systemic (mandatory)"
} else {
"Systemisch (Pflicht)"
},
systemic_mandatory.len().to_string(),
)
.with_accent(if !systemic_mandatory.is_empty() {
design::tokens::INFO
} else {
design::tokens::SUCCESS
}),
MetricStripItem::new(
if en {
"Systemic (optimization)"
} else {
"Systemisch (Optimierung)"
},
systemic_optimization.len().to_string(),
)
.with_accent(design::tokens::NEUTRAL),
MetricStripItem::new(
if en {
"Local (mandatory)"
} else {
"Lokal (Pflicht)"
},
local_mandatory.len().to_string(),
)
.with_accent(if !local_mandatory.is_empty() {
design::tokens::INFO
} else {
design::tokens::SUCCESS
}),
MetricStripItem::new(
if en {
"Local (optimization)"
} else {
"Lokal (Optimierung)"
},
local_optimization.len().to_string(),
)
.with_accent(design::tokens::NEUTRAL),
])
.compact(),
);
const FINDINGS_MATRIX_MAX_ROWS: usize = 30;
let mut matrix_findings: Vec<&FindingGroup> = all_findings.iter().collect();
matrix_findings.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| b.occurrence_count.cmp(&a.occurrence_count))
});
if !matrix_findings.is_empty() {
let (matrix_title, matrix_intro) = if en {
(
"Findings Matrix",
"One row per finding group, sorted by priority — an overview to prioritize by before the detail cards below expand on each row.",
)
} else {
(
"Befundmatrix",
"Eine Zeile je Befundgruppe, sortiert nach Priorität – als Überblick zur Priorisierung, bevor die Detailkarten unten jede Zeile vertiefen.",
)
};
builder = builder.add_component(Label::new(matrix_intro).with_size("10.5pt"));
let mut table = AuditTable::new(vec![
TableColumn::new(if en { "Priority" } else { "Priorität" }).with_width("10%"),
TableColumn::new("WCAG").with_width("10%"),
TableColumn::new(if en { "Finding" } else { "Befund" }).with_width("28%"),
TableColumn::new(if en { "Scope" } else { "Umfang" }).with_width("14%"),
TableColumn::new(if en { "Responsible" } else { "Zuständig" }).with_width("14%"),
TableColumn::new(if en { "Action" } else { "Maßnahme" }).with_width("24%"),
])
.with_title(matrix_title);
for group in matrix_findings.iter().take(FINDINGS_MATRIX_MAX_ROWS) {
let wcag = if group.wcag_criterion.is_empty() {
"n/a".to_string()
} else if group.wcag_level.is_empty() {
group.wcag_criterion.clone()
} else {
format!("{} ({})", group.wcag_criterion, group.wcag_level)
};
let scope = if group.is_component_issue {
if en {
format!("Systemic · {}", group.occurrence_count)
} else {
format!("Systemisch · {}", group.occurrence_count)
}
} else if en {
format!("Local · {}", group.occurrence_count)
} else {
format!("Lokal · {}", group.occurrence_count)
};
table = table.add_row(vec![
priority_label_i18n(group.priority, i18n),
wcag,
truncate_title(&group.title, 48),
scope,
role_label_i18n(group.responsible_role, i18n),
truncate_title(&group.recommendation, 72),
]);
}
builder = builder.add_component(table);
let remaining = matrix_findings
.len()
.saturating_sub(FINDINGS_MATRIX_MAX_ROWS);
if remaining > 0 {
let note = if en {
format!("{remaining} further findings are listed in the detail cards below.")
} else {
format!(
"{remaining} weitere Befunde sind in den Detailkarten unten aufgeführt."
)
};
builder = builder.add_component(
Label::new(note)
.with_size("9pt")
.with_color(design::tokens::NEUTRAL),
);
}
}
let mut rendered_categories = 0usize;
if !systemic_mandatory.is_empty() {
let title = if en {
"Systemic Template & Component Issues (WCAG A/AA)"
} else {
"Systemische Template- & Komponentenfehler (WCAG A/AA)"
};
let desc = if en {
"This pattern suggests a reused component or template — confirming that requires evidence from additional pages. A central fix would likely also resolve the cause on other pages sharing the same pattern."
} else {
"Dieses Muster deutet auf eine wiederverwendete Komponente oder Vorlage hin — bestätigt ist das erst mit Belegen von weiteren Seiten. Eine zentrale Behebung würde die Ursache voraussichtlich auch auf anderen Seiten mit demselben Muster beheben."
};
if rendered_categories > 0 {
builder = builder.add_component(PageBreak::new());
}
rendered_categories += 1;
builder = builder.add_component(
SectionHeaderSplit::new(title, desc)
.with_eyebrow(if en {
"SYSTEMIC COMPLIANCE"
} else {
"SYSTEMISCHE PFLICHT"
})
.with_level(2),
);
for group in systemic_mandatory {
builder = render_finding_technical(
builder,
group,
i18n,
report_ts,
evidence_seq,
acronyms_expanded,
);
}
}
if !systemic_optimization.is_empty() {
let title = if en {
"Systemic Quality & SEO Optimizations"
} else {
"Systemische Qualitäts- & SEO-Optimierungen"
};
let desc = if en {
"These recommendations recur within this single page, which suggests a shared template — confirming that would require checking additional pages."
} else {
"Diese Empfehlungen wiederholen sich innerhalb dieser einen Seite, was auf eine gemeinsame Vorlage hindeutet — bestätigt ist das erst nach Prüfung weiterer Seiten."
};
if rendered_categories > 0 {
builder = builder.add_component(PageBreak::new());
}
rendered_categories += 1;
builder = builder.add_component(
SectionHeaderSplit::new(title, desc)
.with_eyebrow(if en {
"SYSTEMIC OPTIMIZATION"
} else {
"SYSTEMISCHE OPTIMIERUNG"
})
.with_level(2),
);
for group in systemic_optimization {
builder = render_finding_technical(
builder,
group,
i18n,
report_ts,
evidence_seq,
acronyms_expanded,
);
}
}
if !local_mandatory.is_empty() {
let title = if en {
"Local & Editorial Findings (WCAG A/AA)"
} else {
"Einzelfälle & Redaktionelle Befunde (WCAG A/AA)"
};
let desc = if en {
"Single instances of accessibility barriers affecting specific pages, images, or editorial content, usually requiring individual resolution."
} else {
"Punktuelle Barrieren, die nur einzelne Seiten, spezifische Bilder oder redaktionelle Texte betreffen und meist individuell behoben werden müssen."
};
if rendered_categories > 0 {
builder = builder.add_component(PageBreak::new());
}
rendered_categories += 1;
builder = builder.add_component(
SectionHeaderSplit::new(title, desc)
.with_eyebrow(if en {
"LOCAL COMPLIANCE"
} else {
"LOKALE PFLICHT"
})
.with_level(2),
);
for group in local_mandatory {
builder = render_finding_technical(
builder,
group,
i18n,
report_ts,
evidence_seq,
acronyms_expanded,
);
}
}
if !local_optimization.is_empty() {
let title = if en {
"Additional Quality & SEO Recommendations"
} else {
"Ergänzende Qualitäts- & SEO-Empfehlungen"
};
let desc = if en {
"Usability, performance, or SEO recommendations for specific pages."
} else {
"Ergänzende Empfehlungen zur Verbesserung der Ladezeiten, Suchmaschinenoptimierung und Benutzerfreundlichkeit auf bestimmten Seiten."
};
if rendered_categories > 0 {
builder = builder.add_component(PageBreak::new());
}
rendered_categories += 1;
builder = builder.add_component(
SectionHeaderSplit::new(title, desc)
.with_eyebrow(if en {
"LOCAL OPTIMIZATION"
} else {
"LOKALE OPTIMIERUNG"
})
.with_level(2),
);
for group in local_optimization {
builder = render_finding_technical(
builder,
group,
i18n,
report_ts,
evidence_seq,
acronyms_expanded,
);
}
}
let _ = rendered_categories; }
builder
}
fn render_appendix_section(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
if vm.appendix.has_violations {
let (appendix_title, appendix_intro) = if en {
(
"Complete Findings List",
"Raw audit data and the full list of detected violations.",
)
} else {
(
"Vollständige Fundstellen",
"Rohdaten des Audits und die vollständige Liste aller erkannten Verstöße.",
)
};
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(appendix_title, appendix_intro)
.with_eyebrow(if en { "APPENDIX" } else { "ANHANG" })
.with_level(2),
);
builder = builder.add_component(build_cli_snapshot_table(vm, i18n));
let json_note = if en {
"The accompanying JSON report contains the complete machine-readable issue list with selectors, occurrences, and all detail data for automated processing."
} else {
"Der begleitende JSON-Report enthält die vollständige maschinenlesbare Fehlerliste mit Selektoren, Vorkommen und allen Detaildaten für automatisierte Weiterverarbeitung."
};
builder = builder.add_component(Callout::info(json_note).with_title(if en {
"Raw data & processing"
} else {
"Rohdaten & Weiterverarbeitung"
}));
if vm.meta.report_level == ReportLevel::Technical {
for v in &vm.appendix.violations {
let mut desc = v.message.clone();
if let Some(ref fix) = v.fix_suggestion {
desc.push_str(&format!("\n\nFix: {}", fix));
}
desc.push_str(&format!(
"\n\n{} Elemente betroffen",
v.affected_elements.len()
));
let useful_selectors: Vec<&str> = v
.affected_elements
.iter()
.map(|e| e.selector.as_str())
.filter(|s| {
s.contains('.')
|| s.contains('#')
|| s.contains('[')
|| s.contains('>')
|| s.contains(' ')
})
.collect();
if !useful_selectors.is_empty() {
desc.push_str(&format!("\nSelektoren: {}", useful_selectors.join(", ")));
}
builder = builder.add_component(Finding::new(
format!("{} — {}", v.rule, v.rule_name),
map_severity(&v.severity),
&desc,
));
}
} else {
let rows: Vec<ChecklistRow> = vm
.appendix
.violations
.iter()
.map(|v| {
let status = match v.severity {
crate::wcag::Severity::Critical => "bad",
crate::wcag::Severity::High => "warn",
_ => "neutral",
};
ChecklistRow::new(format!("{} — {}", v.rule, v.rule_name), v.message.clone())
.with_status(status)
})
.collect();
builder = builder.add_component(
ChecklistPanel::new(rows).with_title(i18n.t("section-all-violations")),
);
}
}
builder
}
fn render_positive_signals_section(
mut builder: renderreport::engine::ReportBuilder,
signals: &[PositiveSignal],
is_first: bool,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
if signals.is_empty() {
return builder;
}
if !is_first {
builder = builder.add_component(PageBreak::new());
}
let en = i18n.locale() == "en";
let mut rows = Vec::new();
for signal in signals {
let status = if signal.strong {
if en {
"Strong"
} else {
"Stark"
}
} else if en {
"Present"
} else {
"Vorhanden"
};
rows.push(
ChecklistRow::new(&signal.title, format!("{}: {}", status, signal.description))
.with_status(if signal.strong { "good" } else { "info" }),
);
}
builder.add_component(ChecklistPanel::new(rows).with_title(if en {
"Recognized structural patterns"
} else {
"Erkannte Strukturmuster"
}))
}
fn render_dual_viewport_summary_section(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
report: &AuditReport,
is_first: bool,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let has_scores = vm.cover.desktop_score.is_some() && vm.cover.mobile_score.is_some();
let has_screenshot_status = report.page_screenshots.is_some()
|| matches!(
report.screenshot_status,
crate::audit::ScreenshotStatus::Failed(_)
);
if !has_scores && !has_screenshot_status {
return builder;
}
if !is_first {
builder = builder.add_component(PageBreak::new());
}
let en = i18n.locale() == "en";
if report.page_screenshots.is_some() {
builder = builder.add_component(
DevicePreview::new(
super::PAGE_DESKTOP_SCREENSHOT_ASSET,
super::PAGE_MOBILE_SCREENSHOT_ASSET,
)
.with_height(210.0),
);
}
builder = builder.add_component(
Label::new(if en {
"Dual viewport summary"
} else {
"Dual-Viewport-Zusammenfassung"
})
.bold()
.with_size("10.5pt"),
);
let mut items = Vec::new();
if let (Some(desktop), Some(mobile)) = (vm.cover.desktop_score, vm.cover.mobile_score) {
items.push(
MetricStripItem::new(
if en {
"Accessibility - Desktop"
} else {
"Barrierefreiheit - Desktop"
},
format!("{desktop} / 100"),
)
.with_unit(score_range_label(desktop, en))
.with_accent(design::score_color(desktop as u8)),
);
items.push(
MetricStripItem::new(
if en {
"Accessibility - Mobile"
} else {
"Barrierefreiheit - Mobile"
},
format!("{mobile} / 100"),
)
.with_unit(score_range_label(mobile, en))
.with_accent(design::score_color(mobile as u8)),
);
items.push(
MetricStripItem::new(
if en {
"Accessibility - overall"
} else {
"Barrierefreiheit - Gesamt"
},
format!("{} / 100", vm.summary.score),
)
.with_unit(if en {
"70/30 weighted"
} else {
"70/30 gewichtet"
})
.with_accent(design::score_color(vm.summary.score as u8)),
);
}
if !has_scores
&& matches!(
report.screenshot_status,
crate::audit::ScreenshotStatus::Captured
)
{
items.push(
MetricStripItem::new(
if en { "Preview" } else { "Vorschau" },
if en { "Captured" } else { "Erfasst" },
)
.with_accent(design::tokens::SUCCESS),
);
}
if !items.is_empty() {
builder = builder.add_component(MetricStrip::new(items).compact());
}
if let crate::audit::ScreenshotStatus::Failed(reason) = &report.screenshot_status {
builder = builder.add_component(Callout::warning(if en {
format!("Screenshot capture failed: {reason}")
} else {
format!("Screenshot-Erfassung fehlgeschlagen: {reason}")
}));
}
let occurrence_context = report
.dual_viewport
.as_ref()
.map(|dual| {
let desktop = dual.desktop.wcag_results.violations.len();
let mobile = dual.mobile.wcag_results.violations.len();
let combined = vm.severity.total;
if en {
format!("WCAG occurrences before cross-viewport consolidation: {desktop} on desktop and {mobile} on mobile. The normalized combined finding list contains {combined} occurrences; this count is used for evidence and remediation, not as a third score. ")
} else {
format!("WCAG-Vorkommen vor der ansichtsübergreifenden Zusammenführung: {desktop} auf Desktop und {mobile} auf Mobile. Die normalisierte gemeinsame Befundliste enthält {combined} Vorkommen; diese Anzahl dient Evidenz und Maßnahmenplanung, nicht als dritter Score. ")
}
})
.unwrap_or_default();
let explanation = if en {
format!(
"Each viewport accessibility score reflects the severity and variety of automatically detected WCAG findings; 100 means no issue was detected within the automated scope. Lower values mean more remediation pressure. {occurrence_context}The displayed accessibility overall score is calculated from mobile at 70% and desktop at 30%."
)
} else {
format!(
"Jeder Barrierefreiheits-Score bewertet Schwere und Vielfalt der automatisiert erkannten WCAG-Befunde seiner Ansicht; 100 bedeutet, dass im automatisierten Prüfumfang kein Problem erkannt wurde. Niedrigere Werte bedeuten höheren Handlungsdruck. {occurrence_context}Der ausgewiesene Barrierefreiheits-Gesamtwert wird mit 70 % Mobile und 30 % Desktop berechnet."
)
};
builder.add_component(
Label::new(explanation)
.with_size("9pt")
.with_color(design::tokens::MUTED),
)
}
fn score_range_label(score: u32, en: bool) -> &'static str {
crate::registry::SCORE_RANGE.label(score as f32, en)
}
pub(super) fn render_module_sections(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
report: &AuditReport,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let mut is_first = true;
let has_dual_viewport_scores =
vm.cover.desktop_score.is_some() && vm.cover.mobile_score.is_some();
let has_screenshot_status = report.page_screenshots.is_some()
|| matches!(
report.screenshot_status,
crate::audit::ScreenshotStatus::Failed(_)
);
if has_dual_viewport_scores || has_screenshot_status {
builder = render_dual_viewport_summary_section(builder, vm, report, is_first, i18n);
is_first = false;
}
if let Some(ref sx) = vm.module_details.search_experience {
builder = render_search_experience(builder, sx, is_first, i18n);
is_first = false;
}
let en = i18n.locale() == "en";
let mut ki_opened = false;
let mut in_ki_chapter = false;
for module in active_report_modules(report) {
let key = module.module_key();
let is_trust = matches!(
key,
"source_quality" | "ai_visibility" | "content_visibility"
);
if is_trust && !ki_opened {
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(
if en { "AI & Trust" } else { "KI & Vertrauen" },
if en {
"Source quality, AI readability and content visibility — trust and discoverability indicators."
} else {
"Quellenqualität, KI-Lesbarkeit und Content-Sichtbarkeit — Vertrauens- und Auffindbarkeits-Indikatoren."
},
)
.with_eyebrow(if en { "TRUST" } else { "VERTRAUEN" })
.with_level(2),
);
ki_opened = true;
in_ki_chapter = true;
is_first = true; } else if !is_trust && in_ki_chapter {
builder = builder.add_component(PageBreak::new());
in_ki_chapter = false;
is_first = true;
}
let (next_builder, rendered) =
render_active_module_section(builder, key, vm, report, is_first, i18n);
builder = next_builder;
if rendered {
is_first = false;
}
}
builder
}
fn render_active_module_section(
mut builder: renderreport::engine::ReportBuilder,
module_key: &str,
vm: &ReportViewModel,
report: &AuditReport,
is_first: bool,
i18n: &I18n,
) -> (renderreport::engine::ReportBuilder, bool) {
match module_key {
"performance" => {
if let Some(ref perf) = vm.module_details.performance {
builder = render_performance(builder, perf, is_first, i18n);
if !report.experience.budget_violations.is_empty() {
builder = render_budget_violations(
builder,
&report.experience.budget_violations,
i18n,
);
}
return (builder, true);
}
}
"seo" => {
if let Some(ref seo) = vm.module_details.seo {
return (render_seo(builder, seo, is_first, i18n), true);
}
}
"security" => {
if let Some(ref sec) = vm.module_details.security {
return (render_security(builder, sec, is_first, i18n), true);
}
}
"html_conform" => {
if let Some(ref hc) = vm.module_details.html_conform {
return (render_html_conform(builder, hc, is_first, i18n), true);
}
}
"commerce" => {
if let Some(ref c) = vm.module_details.commerce {
return (render_commerce(builder, c, is_first, i18n), true);
}
}
"mobile" => {
if let Some(ref mobile) = vm.module_details.mobile {
return (render_mobile(builder, mobile, is_first, i18n), true);
}
}
"ux" => {
if let Some(ref ux) = vm.module_details.ux {
return (render_ux(builder, ux, is_first, i18n), true);
}
}
"journey" => {
if let Some(ref journey) = vm.module_details.journey {
return (render_journey(builder, journey, is_first, i18n), true);
}
}
"dark_mode" => {
if let Some(ref dm) = vm.module_details.dark_mode {
return (render_dark_mode(builder, dm, is_first, i18n), true);
}
}
"design_quality" => {
if let Some(ref dq) = vm.module_details.design_quality {
return (render_design_quality(builder, dq, is_first, i18n), true);
}
}
"ai_transparency" => {
if let Some(ref at) = vm.module_details.ai_transparency {
return (render_ai_transparency(builder, at, is_first, i18n), true);
}
}
"network_dns" => {
if let Some(ref dns) = vm.module_details.network_dns {
return (render_network_dns(builder, dns, is_first, i18n), true);
}
}
"source_quality" => {
if let Some(ref sq) = vm.module_details.source_quality {
return (render_source_quality(builder, sq, is_first, i18n), true);
}
}
"ai_visibility" => {
if let Some(ref av) = vm.module_details.ai_visibility {
return (render_ai_visibility(builder, av, is_first, i18n), true);
}
}
"content_visibility" => {
if let Some(ref cv) = vm.module_details.content_visibility {
return (render_content_visibility(builder, cv, is_first, i18n), true);
}
}
"best_practices" => {
if let Some(ref bp) = vm.module_details.best_practices {
return (render_best_practices(builder, bp, is_first, i18n), true);
}
}
"tech_stack" => {
if let Some(ref ts) = vm.module_details.tech_stack {
return (render_tech_stack(builder, ts, is_first, i18n), true);
}
}
"patterns" if !vm.positive_signals.is_empty() => {
return (
render_positive_signals_section(builder, &vm.positive_signals, is_first, i18n),
true,
);
}
_ => {}
}
(builder, false)
}
const ROOT_CAUSE_SHOWN: usize = 6;
fn mandatory_root_causes(vm: &ReportViewModel) -> Vec<&FindingGroup> {
let mut findings: Vec<&FindingGroup> = vm
.findings
.all_findings
.iter()
.filter(|f| f.occurrence_count > 0 && f.criticality_tier == CriticalityTier::Mandatory)
.collect();
findings.sort_by_key(|f| std::cmp::Reverse(f.occurrence_count));
findings
}
fn truncate_title(value: &str, max_chars: usize) -> String {
let count = value.chars().count();
if count <= max_chars {
return value.to_string();
}
value
.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>()
+ "…"
}
fn root_cause_lookup(
findings: &[&FindingGroup],
) -> std::collections::HashMap<String, (char, usize, i64)> {
let total_occurrences: usize = findings.iter().map(|f| f.occurrence_count).sum();
findings
.iter()
.enumerate()
.take(ROOT_CAUSE_SHOWN)
.map(|(idx, f)| {
let letter = (b'A' + idx as u8) as char;
let share_pct = if total_occurrences > 0 {
(f.occurrence_count as f64 * 100.0 / total_occurrences as f64).round() as i64
} else {
0
};
(f.rule_id.clone(), (letter, f.occurrence_count, share_pct))
})
.collect()
}
pub(super) fn render_root_cause_analysis(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let title = if en {
"Root Cause Analysis"
} else {
"Ursachenanalyse"
};
let subtitle = if en {
"Many individual findings trace back to a few recurring causes — fixing those has a compounding effect."
} else {
"Viele Einzelbefunde gehen auf wenige wiederkehrende Ursachen zurück – deren Behebung wirkt gebündelt."
};
builder = builder.add_component(
SectionHeaderSplit::new(title, subtitle)
.with_eyebrow(if en { "ROOT CAUSE" } else { "URSACHEN" })
.with_level(2),
);
let findings = mandatory_root_causes(vm);
if findings.is_empty() {
let evidence = &vm.findings.evidence;
let msg = if evidence.is_empty() {
if en {
"No accessibility findings were detected, so no root cause analysis is necessary."
.to_string()
} else {
"Es wurden keine Barrierefreiheits-Befunde erkannt, daher ist keine Ursachenanalyse erforderlich.".to_string()
}
} else if en {
format!(
"No confirmed WCAG violations, so no root cause analysis is necessary. {} accessibility warning(s) and {} manual check(s) remain — they are documented in their own sections and are not scored.",
evidence.warnings, evidence.manual_checks
)
} else {
format!(
"Keine bestätigten WCAG-Verstöße, daher ist keine Ursachenanalyse erforderlich. Es bleiben {} Barrierefreiheits-Warnung(en) und {} manuelle(r) Prüfhinweis(e) — sie stehen in den eigenen Abschnitten und fließen nicht in den Score ein.",
evidence.warnings, evidence.manual_checks
)
};
builder = builder.add_component(if evidence.is_empty() {
Callout::success(&msg)
} else {
Callout::info(&msg)
});
return builder;
}
let lead_in = if en {
"In practice: fixing the right root cause — a shared component or template — often resolves several findings at once, instead of fixing each one individually."
} else {
"Das bedeutet konkret: Ein Fix an der richtigen Stelle – etwa einer wiederverwendeten Komponente oder einem Template – behebt oft mehrere Befunde gleichzeitig, statt jeden einzeln reparieren zu müssen."
};
builder = builder.add_component(Label::new(lead_in).with_size("10.5pt"));
let mut list = List::new().with_title(if en {
"Systemic Root Causes"
} else {
"Erkannte Kernursachen"
});
let mut table_rows = Vec::new();
let mut chart_data: Vec<(String, f64)> = Vec::new();
let total_occurrences: usize = findings.iter().map(|f| f.occurrence_count).sum();
let share_pct = |occurrences: usize| -> i64 {
if total_occurrences > 0 {
(occurrences as f64 * 100.0 / total_occurrences as f64).round() as i64
} else {
0
}
};
let mut shown_occurrences = 0usize;
for (idx, finding) in findings.iter().enumerate().take(ROOT_CAUSE_SHOWN) {
let letter = (b'A' + idx as u8) as char;
let item_title = format!(
"{} {}: {} {} — {}",
if en { "Root Cause" } else { "Ursache" },
letter,
finding.occurrence_count,
if en { "occurrences" } else { "Vorkommen" },
finding.title
);
list = list.add_item(&item_title);
shown_occurrences += finding.occurrence_count;
let short_title = truncate_title(&finding.title, 45);
table_rows.push(vec![
format!("{letter} — {short_title}"),
finding.occurrence_count.to_string(),
format!("{} %", share_pct(finding.occurrence_count)),
]);
chart_data.push((
format!("{letter} — {short_title}"),
finding.occurrence_count as f64,
));
}
if findings.len() > ROOT_CAUSE_SHOWN {
let remaining_causes = findings.len() - ROOT_CAUSE_SHOWN;
let remaining_occurrences = total_occurrences - shown_occurrences;
let label = if en {
format!("Other ({remaining_causes} further causes)")
} else {
format!("Sonstige ({remaining_causes} weitere Ursachen)")
};
table_rows.push(vec![
label.clone(),
remaining_occurrences.to_string(),
format!("{} %", share_pct(remaining_occurrences)),
]);
chart_data.push((label, remaining_occurrences as f64));
}
builder = builder.add_component(list);
if chart_data.len() >= 2 {
builder = builder.add_component(
Chart::bar(if en {
"Occurrence distribution by cause"
} else {
"Verteilung der Vorkommen nach Ursache"
})
.add_series("occurrences", chart_data)
.horizontal(),
);
}
let mut table = AuditTable::new(vec![
TableColumn::new(if en { "Root Cause" } else { "Ursache" }).with_width("40%"),
TableColumn::new(if en { "Occurrences" } else { "Vorkommen" }).with_width("30%"),
TableColumn::new(if en { "Share" } else { "Anteil" }).with_width("30%"),
])
.with_title(if en {
"Distribution of Issues by Root Cause"
} else {
"Verteilung der Mängel nach Ursache"
});
for row in table_rows {
table = table.add_row(row);
}
builder = builder.add_component(table);
builder
}
pub(super) fn render_timeframe_roadmap(
mut builder: renderreport::engine::ReportBuilder,
vm: &ReportViewModel,
i18n: &I18n,
) -> renderreport::engine::ReportBuilder {
let en = i18n.locale() == "en";
let title = if en { "Action Plan" } else { "Maßnahmenplan" };
let subtitle = if en {
"Recommended actions grouped by where the problem lives — including supplementary SEO and quality recommendations without legal relevance."
} else {
"Empfohlene Maßnahmen, gruppiert nach Ebene des Problems — inklusive ergänzender SEO- und Qualitätsempfehlungen ohne Rechtsbezug."
};
builder = builder.add_component(PageBreak::new()).add_component(
SectionHeaderSplit::new(title, subtitle)
.with_eyebrow(if en { "ROADMAP" } else { "MASSNAHMEN" })
.with_level(2),
);
if let Some(note) = build_overall_leverage_note(vm, i18n) {
builder = builder.add_component(Label::new(note).with_size("10.5pt"));
}
let columns = &vm.actions.roadmap_columns;
if columns.is_empty() {
let module_measure_count = cross_module_measures(vm, i18n).len();
let empty_msg = if module_measure_count > 0 {
if en {
format!(
"No prioritized accessibility actions. {module_measure_count} recommendation(s) from other modules (performance, SEO, security, mobile) are listed in their module sections and in the key measures above."
)
} else {
format!(
"Keine priorisierten Barrierefreiheits-Maßnahmen. {module_measure_count} Empfehlung(en) aus anderen Modulen (Performance, SEO, Sicherheit, Mobile) stehen in den jeweiligen Modulabschnitten und in den wichtigsten Maßnahmen oben."
)
}
} else if en {
"No prioritized actions — no findings require remediation.".to_string()
} else {
"Keine priorisierten Maßnahmen — keine Befunde mit Handlungsbedarf.".to_string()
};
builder = builder.add_component(Label::new(empty_msg).with_color(design::tokens::NEUTRAL));
return builder;
}
let root_causes = mandatory_root_causes(vm);
let root_cause_by_rule = root_cause_lookup(&root_causes);
for col in columns {
builder = builder.add_component(
SectionHeaderSplit::new(col.title.clone(), col.description.clone()).with_level(3),
);
for item in &col.items {
let mut why = if !item.benefit.is_empty() {
item.benefit.clone()
} else {
item.risk_effect.clone()
};
let leverage_tag = if en {
format!(
"Remediation leverage: {} — {} occurrence(s) affected · effort: {}",
item.leverage, item.occurrence_count, item.effort
)
} else {
format!(
"Reparaturhebel: {} — {} Vorkommen betroffen · Aufwand: {}",
item.leverage, item.occurrence_count, item.effort
)
};
why = format!("{why}\n\n{leverage_tag}");
if let Some((letter, occurrence_count, share_pct)) =
root_cause_by_rule.get(&item.rule_id)
{
let tag = if en {
format!("→ Root Cause {letter}, {occurrence_count} occurrences ({share_pct}%)")
} else {
format!("→ Ursache {letter}, {occurrence_count} Vorkommen ({share_pct} %)")
};
why = format!("{why}\n\n{tag}");
}
builder = builder.add_component(RecommendationCard::new(item.action.clone(), why));
}
}
builder
}
#[cfg(test)]
mod top_measure_tests {
use super::*;
fn measure(module: &str, title: &str, detail: &str) -> TopMeasure {
TopMeasure {
module: module.to_string(),
title: title.to_string(),
detail: detail.to_string(),
}
}
#[test]
fn collapsed_measure_reports_how_many_it_stands_for() {
let rendered =
measure("Sicherheit", "Content-Security-Policy", "unsafe-inline").render(1, 2, false);
assert_eq!(
rendered,
"1. Sicherheit — Content-Security-Policy: unsafe-inline (+2 weitere gleicher Art)"
);
}
#[test]
fn single_measure_has_no_collapse_suffix() {
let rendered =
measure("Security", "Content-Security-Policy", "unsafe-inline").render(3, 0, true);
assert_eq!(
rendered,
"3. Security — Content-Security-Policy: unsafe-inline"
);
}
#[test]
fn titleless_measure_renders_without_separator() {
let rendered = measure("Performance", "", "DOM-Struktur verschlanken.").render(2, 0, false);
assert_eq!(rendered, "2. Performance — DOM-Struktur verschlanken.");
}
}
#[cfg(test)]
pub(super) mod risk_and_strength_tests {
use super::*;
use crate::audit::management_risk::ManagementRiskKind;
#[test]
fn score_derivation_reproduces_the_overall_score() {
for (desktop, mobile, security, expected) in [
(94u32, 91u32, Some(95u32), 92u32),
(41, 49, Some(30), 45),
(80, 80, None, 80),
] {
let blended = ((desktop as f64 * 30.0 + mobile as f64 * 70.0) / 100.0).round() as u32;
let overall = match security {
Some(sec) => ((blended as f64 * 90.0 + sec as f64 * 10.0) / 100.0).round() as u32,
None => blended,
};
assert_eq!(
overall, expected,
"desktop {desktop} / mobile {mobile} / security {security:?}",
);
}
}
fn rows(kinds: &[ManagementRiskKind], en: bool) -> Vec<DimensionRow> {
kinds
.iter()
.map(|kind| DimensionRow {
label: kind.dimension(en),
description: kind.rationale(en),
status: kind.tier().status(),
})
.collect()
}
#[test]
fn mixed_report_buckets_bad_dimensions_as_risks_and_good_ones_as_strengths() {
let kinds = [
ManagementRiskKind::Legal {
legal_flags: 2,
critical: 2,
high: 1,
blocking_issues: 0,
},
ManagementRiskKind::Conversion {
accessibility: 45,
performance: Some(95),
mobile: Some(95),
blocking_issues: 0,
},
ManagementRiskKind::Seo { score: Some(95) },
ManagementRiskKind::PerformanceMobile {
performance: Some(95),
mobile: Some(95),
},
];
let dimensions = rows(&kinds, false);
let risks: Vec<&DimensionRow> = dimensions.iter().filter(|d| d.status != "good").collect();
let strengths: Vec<&DimensionRow> =
dimensions.iter().filter(|d| d.status == "good").collect();
assert_eq!(risks.len(), 2, "legal and conversion should be risks");
assert!(
risks
.iter()
.all(|d| d.label != ManagementRiskKind::Seo { score: Some(95) }.dimension(false)),
"a good SEO score must never appear in the risks bucket",
);
assert_eq!(
strengths.len(),
2,
"SEO and performance/mobile should be strengths",
);
}
#[test]
fn clean_report_puts_every_dimension_in_strengths() {
let kinds = [
ManagementRiskKind::Legal {
legal_flags: 0,
critical: 0,
high: 0,
blocking_issues: 0,
},
ManagementRiskKind::AssistiveTechnology {
critical: 0,
high: 0,
blocking_issues: 0,
},
ManagementRiskKind::Conversion {
accessibility: 95,
performance: Some(95),
mobile: Some(95),
blocking_issues: 0,
},
ManagementRiskKind::Seo { score: Some(95) },
ManagementRiskKind::PerformanceMobile {
performance: Some(95),
mobile: Some(95),
},
ManagementRiskKind::Trust {
accessibility: 95,
critical: 0,
high: 0,
},
ManagementRiskKind::Project {
count: 0,
pages: None,
},
];
let dimensions = rows(&kinds, false);
assert!(
dimensions.iter().all(|d| d.status == "good"),
"all dimensions should be good, got: {:?}",
dimensions
.iter()
.map(|d| (&d.label, d.status))
.collect::<Vec<_>>(),
);
}
#[test]
fn risks_partition_sorts_bad_before_warn_stably() {
let kinds = [
ManagementRiskKind::Legal {
legal_flags: 1,
critical: 0,
high: 0,
blocking_issues: 0,
},
ManagementRiskKind::AssistiveTechnology {
critical: 0,
high: 1,
blocking_issues: 0,
},
ManagementRiskKind::Seo { score: Some(50) },
ManagementRiskKind::PerformanceMobile {
performance: Some(70),
mobile: Some(95),
},
];
let (mut risks, _strengths): (Vec<DimensionRow>, Vec<DimensionRow>) = rows(&kinds, true)
.into_iter()
.partition(|d| d.status != "good");
risks.sort_by_key(|d| if d.status == "bad" { 0u8 } else { 1u8 });
let statuses: Vec<&str> = risks.iter().map(|d| d.status).collect();
assert_eq!(statuses, vec!["bad", "bad", "warn", "warn"]);
assert_eq!(risks[0].label, "Legal / BFSG-EAA");
assert_eq!(risks[1].label, "SEO / visibility");
}
pub(in crate::output::pdf) fn test_report_view_model() -> ReportViewModel {
let mut results = crate::wcag::WcagResults::new();
results.nodes_checked = 50;
results.passes = 10;
results.add_violation(
crate::wcag::Violation::new(
"1.1.1",
"Non-text Content",
crate::cli::WcagLevel::A,
crate::wcag::Severity::High,
"Image missing alt",
"node-img",
)
.with_selector("img.hero")
.with_html_snippet("<img class=\"hero\">")
.with_fix("Add alt"),
);
let report = AuditReport::new(
"https://example.com".into(),
crate::cli::WcagLevel::AA,
results,
100,
);
let normalized = crate::audit::normalized::normalize(&report);
let config = ReportConfig::default();
crate::output::builder::build_view_model(&normalized, &config)
}
#[test]
fn appendix_table_does_not_duplicate_interpretation_when_card_context_matches() {
let mut vm = test_report_view_model();
vm.modules.dashboard = vec![crate::output::report_model::ModuleScore {
name: "Sichtbarkeit & Nutzerverständnis".into(),
score: 56,
measurement_type: "composite".into(),
interpretation:
"Eingeschränkte Search Experience: Die Seite ist nicht ausreichend verständlich."
.into(),
card_context:
"Eingeschränkte Search Experience: Die Seite ist nicht ausreichend verständlich."
.into(),
score_context: String::new(),
key_lever: String::new(),
good_threshold: 75,
warn_threshold: 50,
}];
let i18n = I18n::new("de").unwrap();
let table = build_cli_snapshot_table(&vm, &i18n);
let row = table
.rows
.iter()
.find(|r| r.get(1).and_then(|v| v.as_str()) == Some("Sichtbarkeit & Nutzerverständnis"))
.expect("search experience row must be present");
let value = row[2].as_str().unwrap();
assert_eq!(
value.matches("Eingeschränkte Search Experience").count(),
1,
"interpretation sentence must appear exactly once, got: {value}"
);
}
#[test]
fn subcategory_breakdown_handles_all_weak_entries() {
let i18n = I18n::new("de").unwrap();
let builder = renderreport::engine::ReportBuilder::new("single");
let entries = vec![
("ARIA".to_string(), 10),
("Landmarks".to_string(), 20),
("Semantik".to_string(), 50),
];
let builder = render_subcategory_breakdown(builder, "Accessibility", &entries, &i18n);
let report = builder.build();
assert_eq!(report.components.len(), 1);
let json = serde_json::to_string(&report.components[0]).unwrap();
assert!(json.contains("Kritische Schwächen:"));
assert!(!json.contains("Stärkere Teilbereiche"));
assert!(json.contains("ARIA 10"));
}
#[test]
fn subcategory_breakdown_handles_all_stable_entries() {
let i18n = I18n::new("de").unwrap();
let builder = renderreport::engine::ReportBuilder::new("single");
let entries = vec![
("Formulare".to_string(), 100),
("Alternativtexte".to_string(), 90),
];
let builder = render_subcategory_breakdown(builder, "Barrierefreiheit", &entries, &i18n);
let report = builder.build();
assert_eq!(report.components.len(), 1);
let json = serde_json::to_string(&report.components[0]).unwrap();
assert!(!json.contains("Kritische Schwächen"));
assert!(json.contains("Stärkere Teilbereiche innerhalb der Barrierefreiheit:"));
assert!(json.contains("Formulare 100"));
}
#[test]
fn subcategory_breakdown_handles_contrast_entries() {
let i18n = I18n::new("de").unwrap();
let builder = renderreport::engine::ReportBuilder::new("single");
let entries = vec![("ARIA".to_string(), 10), ("Formulare".to_string(), 100)];
let builder = render_subcategory_breakdown(builder, "Barrierefreiheit", &entries, &i18n);
let report = builder.build();
assert_eq!(report.components.len(), 1);
let json = serde_json::to_string(&report.components[0]).unwrap();
assert!(json.contains("Kritische Schwächen:"));
assert!(json.contains("Stärkere Teilbereiche innerhalb der Barrierefreiheit:"));
}
#[test]
fn subcategory_breakdown_handles_empty_entries() {
let i18n = I18n::new("de").unwrap();
let builder = renderreport::engine::ReportBuilder::new("single");
let entries: Vec<(String, u32)> = vec![];
let builder = render_subcategory_breakdown(builder, "Accessibility", &entries, &i18n);
let report = builder.build();
assert_eq!(report.components.len(), 0);
}
#[test]
fn part_divider_part_1_and_2_do_not_have_trailing_break() {
let i18n = I18n::new("de").unwrap();
let b1 = renderreport::engine::ReportBuilder::new("single");
let r1 = render_part_divider(b1, 1, "T1", "I1", "Z1", "B1", &i18n).build();
assert_eq!(r1.components.len(), 3);
let b2 = renderreport::engine::ReportBuilder::new("single");
let r2 = render_part_divider(b2, 2, "T2", "I2", "Z2", "B2", &i18n).build();
assert_eq!(r2.components.len(), 4);
}
#[test]
fn part_divider_part_3_acts_as_standalone_trennseite() {
let i18n = I18n::new("de").unwrap();
let b3 = renderreport::engine::ReportBuilder::new("single");
let r3 = render_part_divider(b3, 3, "T3", "I3", "Z3", "B3", &i18n).build();
assert_eq!(r3.components.len(), 6);
let json = serde_json::to_string(&r3.components).unwrap();
assert!(json.contains("TECHNISCHER ANHANG"));
assert!(json.contains("divider"));
}
#[test]
fn part_divider_part_3_english_eyebrow() {
let i18n = I18n::new("en").unwrap();
let b3 = renderreport::engine::ReportBuilder::new("single");
let r3 = render_part_divider(b3, 3, "T3", "I3", "Z3", "B3", &i18n).build();
let json = serde_json::to_string(&r3.components).unwrap();
assert!(json.contains("TECHNICAL APPENDIX"));
}
}