pub mod module;
pub use module::SourceQualityModule;
use serde::{Deserialize, Serialize};
use crate::audit::AuditReport;
use crate::seo::schema::SchemaType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceQualityAnalysis {
pub score: u32,
pub band: String,
pub substance: DimensionScore,
pub consistency: DimensionScore,
pub authority: DimensionScore,
pub disclaimer: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DimensionKind {
Substance,
Consistency,
Authority,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DimensionScore {
pub kind: DimensionKind,
pub name: String,
pub score: u32,
pub label: String,
pub signals: Vec<QualitySignal>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum QualitySignalKind {
HeadingStructure,
ContentVolume,
StructuredData,
MetaDescription,
LanguageDeclaration,
ImageDescriptions,
SemanticStructure,
Https,
SecurityHeaders,
PublisherIdentity,
CanonicalUrl,
SocialMeta,
TrustSignals,
HeadingHierarchy,
NamedControls,
NoCriticalErrors,
LanguageConsistency,
ScoreStability,
MetaDescriptionCoverage,
StructuredDataCoverage,
LanguageDeclarationCoverage,
HstsCoverage,
ErrorFreePages,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SignalValues {
#[serde(skip_serializing_if = "Option::is_none")]
pub depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_h1: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub starts_with_h1: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub word_count: Option<u32>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub schema_types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub missing_main: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub score: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub percent: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub std_dev: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QualitySignal {
pub kind: QualitySignalKind,
pub name: String,
pub present: bool,
#[serde(default = "default_fulfilment")]
pub fulfilment: f32,
pub weight: f32,
pub detail: String,
#[serde(default)]
pub values: SignalValues,
}
fn default_fulfilment() -> f32 {
1.0
}
impl QualitySignal {
fn new(kind: QualitySignalKind, present: bool, weight: f32, values: SignalValues) -> Self {
Self::graded(
kind,
present,
if present { 1.0 } else { 0.0 },
weight,
values,
)
}
fn graded(
kind: QualitySignalKind,
present: bool,
fulfilment: f32,
weight: f32,
values: SignalValues,
) -> Self {
let (name, detail) = source_quality_signal_text(kind, present, &values, true);
QualitySignal {
kind,
name,
present,
fulfilment: fulfilment.clamp(0.0, 1.0),
weight,
detail,
values,
}
}
}
const DISCLAIMER_DE: &str = "Diese Bewertung basiert ausschließlich auf technischen Signalen \
(Struktur, Semantik, Metadaten, Sicherheit). Sie beurteilt nicht, ob die \
dargestellten Inhalte inhaltlich korrekt, vollständig oder aktuell sind.";
const DISCLAIMER_EN: &str = "This assessment is based solely on technical signals \
(structure, semantics, metadata, security). It does not judge whether the \
presented content is factually correct, complete or up to date.";
pub fn source_quality_disclaimer(en: bool) -> String {
if en { DISCLAIMER_EN } else { DISCLAIMER_DE }.to_string()
}
pub fn source_quality_dimension_name(kind: DimensionKind, en: bool) -> &'static str {
match (kind, en) {
(DimensionKind::Substance, true) => "Substance",
(DimensionKind::Substance, false) => "Substanz",
(DimensionKind::Consistency, true) => "Consistency",
(DimensionKind::Consistency, false) => "Konsistenz",
(DimensionKind::Authority, true) => "Authority",
(DimensionKind::Authority, false) => "Autorität",
}
}
pub fn source_quality_no_data_label(en: bool) -> String {
if en { "No data" } else { "Keine Daten" }.to_string()
}
pub fn source_quality_dimension_label(score: u32, en: bool) -> String {
crate::registry::FIVE_BAND
.label(score as f32, en)
.to_string()
}
pub fn source_quality_signal_text(
kind: QualitySignalKind,
present: bool,
values: &SignalValues,
en: bool,
) -> (String, String) {
use QualitySignalKind::*;
let name: String = match (kind, en) {
(HeadingStructure, true) => "Heading structure".into(),
(HeadingStructure, false) => "Überschriftenstruktur".into(),
(ContentVolume, true) => "Content volume".into(),
(ContentVolume, false) => "Inhaltsumfang".into(),
(StructuredData, true) | (StructuredDataCoverage, true) => "Structured data".into(),
(StructuredData, false) | (StructuredDataCoverage, false) => "Strukturierte Daten".into(),
(MetaDescription, true) => "Meta description".into(),
(MetaDescription, false) => "Meta-Beschreibung".into(),
(LanguageDeclaration, true) | (LanguageDeclarationCoverage, true) => {
"Language declaration".into()
}
(LanguageDeclaration, false) | (LanguageDeclarationCoverage, false) => {
"Sprachdeklaration".into()
}
(ImageDescriptions, true) => "Image descriptions".into(),
(ImageDescriptions, false) => "Bildbeschreibungen".into(),
(SemanticStructure, true) => "Semantic structure".into(),
(SemanticStructure, false) => "Semantische Struktur".into(),
(Https, _) => "HTTPS".into(),
(SecurityHeaders, true) => "Security headers".into(),
(SecurityHeaders, false) => "Sicherheits-Header".into(),
(PublisherIdentity, true) => "Publisher identity".into(),
(PublisherIdentity, false) => "Herausgeber-Identität".into(),
(CanonicalUrl, _) => "Canonical URL".into(),
(SocialMeta, true) => "Social meta".into(),
(SocialMeta, false) => "Social-Meta".into(),
(TrustSignals, true) => "Trust signals".into(),
(TrustSignals, false) => "Vertrauenssignale".into(),
(HeadingHierarchy, true) => "Heading hierarchy".into(),
(HeadingHierarchy, false) => "Überschriften-Hierarchie".into(),
(NamedControls, true) => "Named controls".into(),
(NamedControls, false) => "Benannte Bedienelemente".into(),
(NoCriticalErrors, true) => "No critical errors".into(),
(NoCriticalErrors, false) => "Keine kritischen Fehler".into(),
(LanguageConsistency, true) => "Language consistency".into(),
(LanguageConsistency, false) => "Sprachkonsistenz".into(),
(ScoreStability, true) => "Score stability".into(),
(ScoreStability, false) => "Score-Stabilität".into(),
(MetaDescriptionCoverage, true) => "Meta descriptions".into(),
(MetaDescriptionCoverage, false) => "Meta-Beschreibungen".into(),
(HstsCoverage, true) => "HSTS coverage".into(),
(HstsCoverage, false) => "HSTS-Abdeckung".into(),
(ErrorFreePages, true) => "Error-free pages".into(),
(ErrorFreePages, false) => "Fehlerfreie Seiten".into(),
};
let count = values.count.unwrap_or(0);
let percent = values.percent.unwrap_or(0);
let detail: String = match kind {
HeadingStructure => {
let depth = values.depth.unwrap_or(0);
let has_h1 = values.has_h1.unwrap_or(false);
let h1_count = count;
let starts_with_h1 = values.starts_with_h1.unwrap_or(true);
if !has_h1 {
if en {
"No H1 heading present".into()
} else {
"Keine H1-Überschrift vorhanden".into()
}
} else if h1_count != 1 {
if en {
format!("{h1_count} H1 headings found (should be exactly one)")
} else {
format!("{h1_count} H1-Überschriften gefunden (sollte genau eine sein)")
}
} else if !starts_with_h1 {
if en {
"Exactly one H1, but it is not the first heading in the document".into()
} else {
"Genau eine H1, aber nicht die erste Überschrift im Dokument".into()
}
} else if depth >= 3 {
if en {
format!("Structured outline down to H{}", depth)
} else {
format!("Strukturierte Gliederung bis H{}", depth)
}
} else if en {
format!("Flat outline (only down to H{})", depth)
} else {
format!("Flache Gliederung (nur bis H{})", depth)
}
}
ContentVolume => {
let word_count = values.word_count.unwrap_or(0);
if en {
format!(
"{} words{}",
word_count,
if present {
""
} else {
" (heuristic: typically ≥ 300 words recommended)"
}
)
} else {
format!(
"{} Wörter{}",
word_count,
if present {
""
} else {
" (Heuristik: typisch ≥ 300 Wörter empfohlen)"
}
)
}
}
StructuredData => {
if present && !values.schema_types.is_empty() {
format!("Schema.org: {}", values.schema_types.join(", "))
} else if present {
if en {
"Schema.org: structured data detected (microdata/RDFa, no JSON-LD types)".into()
} else {
"Schema.org: strukturierte Daten erkannt (Microdata/RDFa, keine JSON-LD-Typen)"
.into()
}
} else if en {
"No structured data".into()
} else {
"Keine strukturierten Daten".into()
}
}
MetaDescription => {
if present {
if en {
"Meaningful meta description present".into()
} else {
"Aussagekräftige Meta-Beschreibung vorhanden".into()
}
} else if en {
"Missing or too short meta description".into()
} else {
"Keine oder zu kurze Meta-Beschreibung".into()
}
}
LanguageDeclaration => {
if present {
let lang = values.lang.as_deref().unwrap_or("?");
if en {
format!("Language declared: {}", lang)
} else {
format!("Sprache deklariert: {}", lang)
}
} else if en {
"No language declaration".into()
} else {
"Keine Sprachdeklaration".into()
}
}
ImageDescriptions => {
if present {
if en {
"All images have alternative text".into()
} else {
"Alle Bilder haben Alternativtexte".into()
}
} else if en {
format!("{} images without alternative text", count)
} else {
format!("{} Bilder ohne Alternativtext", count)
}
}
SemanticStructure => {
let missing_main = values.missing_main.unwrap_or(false);
if present {
if en {
"Correct landmark regions".into()
} else {
"Korrekte Landmark-Regionen".into()
}
} else if missing_main {
if en {
"<main> landmark not detectable in the accessibility tree".into()
} else {
"<main>-Landmark im Accessibility Tree nicht nachweisbar".into()
}
} else if en {
format!("{} structural issues", count)
} else {
format!("{} Strukturprobleme", count)
}
}
Https => {
if present {
if en {
"Encrypted connection".into()
} else {
"Verschlüsselte Verbindung".into()
}
} else if en {
"No HTTPS encryption".into()
} else {
"Keine HTTPS-Verschlüsselung".into()
}
}
SecurityHeaders => {
if en {
format!("{}/4 relevant security headers set", count)
} else {
format!("{}/4 relevante Security-Header gesetzt", count)
}
}
PublisherIdentity => {
if present {
if en {
"Organization/publisher identified via Schema.org".into()
} else {
"Organisation/Herausgeber per Schema.org identifiziert".into()
}
} else if en {
"No publisher markup".into()
} else {
"Kein Herausgeber-Markup".into()
}
}
CanonicalUrl => {
if present {
if en {
"Canonical URL declared".into()
} else {
"Kanonische URL deklariert".into()
}
} else if en {
"No canonical URL".into()
} else {
"Keine Canonical-URL".into()
}
}
SocialMeta => {
if present {
if en {
"Open Graph metadata present".into()
} else {
"Open Graph Metadaten vorhanden".into()
}
} else if en {
"Incomplete social metadata".into()
} else {
"Unvollständige Social-Metadaten".into()
}
}
TrustSignals => {
let score = values.score.unwrap_or(0.0);
if en {
format!(
"UX trust score: {:.0}{}",
score,
if present { "" } else { " (weak)" }
)
} else {
format!(
"UX Trust-Score: {:.0}{}",
score,
if present { "" } else { " (schwach)" }
)
}
}
HeadingHierarchy => {
if present {
if en {
"Gapless heading hierarchy".into()
} else {
"Lückenlose Überschriften-Hierarchie".into()
}
} else if en {
format!("{} hierarchy issues", count)
} else {
format!("{} Hierarchie-Probleme", count)
}
}
NamedControls => {
if present {
if en {
"All interactive elements correctly named".into()
} else {
"Alle interaktiven Elemente korrekt benannt".into()
}
} else if en {
format!("{} elements without an accessible name", count)
} else {
format!("{} Elemente ohne zugänglichen Namen", count)
}
}
NoCriticalErrors => {
if present {
if en {
"No critical accessibility violations".into()
} else {
"Keine kritischen Accessibility-Verstöße".into()
}
} else if en {
format!("{} critical violations", count)
} else {
format!("{} kritische Verstöße", count)
}
}
LanguageConsistency => {
if present {
if en {
"Language correctly declared".into()
} else {
"Sprache korrekt deklariert".into()
}
} else if en {
"Missing language declaration".into()
} else {
"Fehlende Sprachdeklaration".into()
}
}
ScoreStability => {
let std_dev = values.std_dev.unwrap_or(0.0);
if en {
format!(
"Standard deviation: {:.1}{}",
std_dev,
if present {
" (stable)"
} else {
" (inconsistent)"
}
)
} else {
format!(
"Standardabweichung: {:.1}{}",
std_dev,
if present {
" (stabil)"
} else {
" (inkonsistent)"
}
)
}
}
MetaDescriptionCoverage => {
if en {
format!("{}% of pages with a meta description", percent)
} else {
format!("{}% der Seiten mit Meta-Beschreibung", percent)
}
}
StructuredDataCoverage => {
if en {
format!("{}% of pages with Schema.org", percent)
} else {
format!("{}% der Seiten mit Schema.org", percent)
}
}
LanguageDeclarationCoverage => {
if en {
format!("{}% of pages with a language declaration", percent)
} else {
format!("{}% der Seiten mit Sprachdeklaration", percent)
}
}
HstsCoverage => {
if en {
format!("{}% of pages with HSTS", percent)
} else {
format!("{}% der Seiten mit HSTS", percent)
}
}
ErrorFreePages => {
if en {
format!("{}% of pages without critical errors", percent)
} else {
format!("{}% der Seiten ohne kritische Fehler", percent)
}
}
};
(name, detail)
}
pub fn analyze_source_quality(report: &AuditReport) -> SourceQualityAnalysis {
let substance = evaluate_substance(report);
let consistency = evaluate_single_page_consistency(report);
let authority = evaluate_authority(report);
let score = weighted_average(&[
(substance.score, 40),
(consistency.score, 25),
(authority.score, 35),
]);
SourceQualityAnalysis {
score,
band: crate::registry::FIVE_BAND
.label(score as f32, true)
.to_string(),
substance,
consistency,
authority,
disclaimer: source_quality_disclaimer(true),
}
}
pub fn analyze_source_quality_batch(reports: &[AuditReport]) -> SourceQualityAnalysis {
if reports.is_empty() {
return empty_analysis();
}
let substance_scores: Vec<DimensionScore> = reports.iter().map(evaluate_substance).collect();
let authority_scores: Vec<DimensionScore> = reports.iter().map(evaluate_authority).collect();
let avg_substance = average_dimensions(&substance_scores, DimensionKind::Substance);
let avg_authority = average_dimensions(&authority_scores, DimensionKind::Authority);
let consistency = evaluate_cross_page_consistency(reports);
let score = weighted_average(&[
(avg_substance.score, 35),
(consistency.score, 30),
(avg_authority.score, 35),
]);
SourceQualityAnalysis {
score,
band: crate::registry::FIVE_BAND
.label(score as f32, true)
.to_string(),
substance: avg_substance,
consistency,
authority: avg_authority,
disclaimer: source_quality_disclaimer(true),
}
}
fn evaluate_substance(report: &AuditReport) -> DimensionScore {
use QualitySignalKind::*;
let mut signals = Vec::new();
if let Some(seo) = &report.discoverability.seo {
let h1_count = seo.headings.h1_count;
let has_h1 = h1_count > 0;
let starts_with_h1 = seo
.headings
.headings
.first()
.map(|h| h.level == 1)
.unwrap_or(false);
let well_formed_h1 = h1_count == 1 && starts_with_h1;
let depth = seo
.headings
.headings
.iter()
.map(|h| h.level)
.max()
.unwrap_or(0);
let good_depth = depth >= 3;
signals.push(QualitySignal::new(
HeadingStructure,
well_formed_h1 && good_depth,
0.20,
SignalValues {
depth: Some(depth as u32),
has_h1: Some(has_h1),
starts_with_h1: Some(starts_with_h1),
count: Some(h1_count as u32),
..Default::default()
},
));
let word_count = seo.technical.word_count;
signals.push(QualitySignal::new(
ContentVolume,
word_count >= 300,
0.15,
SignalValues {
word_count: Some(word_count),
..Default::default()
},
));
let has_schema = seo.structured_data.has_structured_data;
let schema_types: Vec<String> = seo
.structured_data
.types
.iter()
.map(|t| t.as_str().to_string())
.collect();
signals.push(QualitySignal::new(
StructuredData,
has_schema,
0.20,
SignalValues {
schema_types,
..Default::default()
},
));
let has_meta_desc = seo.meta.description.as_ref().is_some_and(|d| d.len() >= 50);
signals.push(QualitySignal::new(
MetaDescription,
has_meta_desc,
0.10,
SignalValues::default(),
));
let has_lang = seo.technical.has_lang;
signals.push(QualitySignal::new(
LanguageDeclaration,
has_lang,
0.10,
SignalValues {
lang: seo.technical.lang.clone(),
..Default::default()
},
));
}
let image_violations = report.accessibility.wcag_results.count_by_rule("1.1.1");
signals.push(QualitySignal::new(
ImageDescriptions,
image_violations == 0,
0.15,
SignalValues {
count: Some(image_violations as u32),
..Default::default()
},
));
let landmark_violations = report
.accessibility
.wcag_results
.violations
.iter()
.filter(|v| v.rule_name.contains("Landmark"))
.count();
let missing_main_in_ax = report
.interactive_findings
.iter()
.any(|f| f.category == "Landmark" && f.message.contains("Kein <main>-Landmark"));
let good_landmarks = landmark_violations == 0 && !missing_main_in_ax;
signals.push(QualitySignal::new(
SemanticStructure,
good_landmarks,
0.10,
SignalValues {
count: Some(landmark_violations as u32),
missing_main: Some(missing_main_in_ax),
..Default::default()
},
));
build_dimension(DimensionKind::Substance, signals)
}
fn evaluate_authority(report: &AuditReport) -> DimensionScore {
use QualitySignalKind::*;
let mut signals = Vec::new();
let has_https = report.url.starts_with("https://");
signals.push(QualitySignal::new(
Https,
has_https,
0.15,
SignalValues::default(),
));
if let Some(sec) = &report.security {
let header_count = [
sec.headers.strict_transport_security.is_some(),
sec.headers.content_security_policy.is_some(),
sec.headers.x_content_type_options.is_some(),
sec.headers.referrer_policy.is_some(),
]
.iter()
.filter(|&&b| b)
.count();
signals.push(QualitySignal::new(
SecurityHeaders,
header_count >= 3,
0.15,
SignalValues {
count: Some(header_count as u32),
..Default::default()
},
));
}
if let Some(seo) = &report.discoverability.seo {
let has_org = seo.structured_data.types.iter().any(|t| {
t.is_organization_like() || matches!(t, SchemaType::Person | SchemaType::WebSite)
});
signals.push(QualitySignal::new(
PublisherIdentity,
has_org,
0.20,
SignalValues::default(),
));
let has_canonical = seo.technical.has_canonical;
signals.push(QualitySignal::new(
CanonicalUrl,
has_canonical,
0.10,
SignalValues::default(),
));
let has_og = seo
.social
.open_graph
.as_ref()
.is_some_and(|og| og.title.is_some() && og.description.is_some());
signals.push(QualitySignal::new(
SocialMeta,
has_og,
0.10,
SignalValues::default(),
));
}
if let Some(ux) = &report.ux {
let trust_good = ux.trust_signals.score >= 70;
signals.push(QualitySignal::new(
TrustSignals,
trust_good,
0.15,
SignalValues {
score: Some(ux.trust_signals.score as f32),
..Default::default()
},
));
}
build_dimension(DimensionKind::Authority, signals)
}
fn evaluate_single_page_consistency(report: &AuditReport) -> DimensionScore {
use QualitySignalKind::*;
let mut signals = Vec::new();
if let Some(seo) = &report.discoverability.seo {
let issue_count = seo.headings.issues.len();
signals.push(QualitySignal::new(
HeadingHierarchy,
issue_count == 0,
0.25,
SignalValues {
count: Some(issue_count as u32),
..Default::default()
},
));
}
let unnamed_interactive = report
.accessibility
.wcag_results
.violations
.iter()
.filter(|v| {
crate::taxonomy::RuleLookup::by_legacy_wcag_id(
crate::audit::normalized::wcag_group_key(v),
)
.is_some_and(|rule| crate::taxonomy::is_missing_name_or_role(rule.id))
})
.count();
signals.push(QualitySignal::new(
NamedControls,
unnamed_interactive == 0,
0.25,
SignalValues {
count: Some(unnamed_interactive as u32),
..Default::default()
},
));
let critical = report.accessibility.statistics.critical;
signals.push(QualitySignal::new(
NoCriticalErrors,
critical == 0,
0.25,
SignalValues {
count: Some(critical as u32),
..Default::default()
},
));
if let Some(seo) = &report.discoverability.seo {
let has_lang = seo.technical.has_lang;
signals.push(QualitySignal::new(
LanguageConsistency,
has_lang,
0.25,
SignalValues::default(),
));
}
build_dimension(DimensionKind::Consistency, signals)
}
fn evaluate_cross_page_consistency(reports: &[AuditReport]) -> DimensionScore {
use QualitySignalKind::*;
let total = reports.len() as f32;
let mut signals = Vec::new();
let scores: Vec<f32> = reports.iter().map(|r| r.accessibility.score).collect();
let mean = scores.iter().sum::<f32>() / total;
let variance = scores.iter().map(|s| (s - mean).powi(2)).sum::<f32>() / total;
let std_dev = variance.sqrt();
signals.push(QualitySignal::graded(
ScoreStability,
std_dev < 15.0,
((30.0 - std_dev) / 15.0).clamp(0.0, 1.0),
0.20,
SignalValues {
std_dev: Some(std_dev),
..Default::default()
},
));
let with_meta: usize = reports
.iter()
.filter(|r| {
r.discoverability
.seo
.as_ref()
.and_then(|s| s.meta.description.as_ref())
.is_some_and(|d| d.len() >= 50)
})
.count();
let meta_pct = (with_meta as f32 / total * 100.0) as u32;
signals.push(QualitySignal::graded(
MetaDescriptionCoverage,
meta_pct >= 90,
meta_pct as f32 / 100.0,
0.15,
SignalValues {
percent: Some(meta_pct),
..Default::default()
},
));
let with_schema: usize = reports
.iter()
.filter(|r| {
r.discoverability
.seo
.as_ref()
.is_some_and(|s| s.structured_data.has_structured_data)
})
.count();
let schema_pct = (with_schema as f32 / total * 100.0) as u32;
signals.push(QualitySignal::graded(
StructuredDataCoverage,
schema_pct >= 80,
schema_pct as f32 / 100.0,
0.15,
SignalValues {
percent: Some(schema_pct),
..Default::default()
},
));
let with_lang: usize = reports
.iter()
.filter(|r| {
r.discoverability
.seo
.as_ref()
.is_some_and(|s| s.technical.has_lang)
})
.count();
let lang_pct = (with_lang as f32 / total * 100.0) as u32;
signals.push(QualitySignal::graded(
LanguageDeclarationCoverage,
lang_pct >= 95,
lang_pct as f32 / 100.0,
0.15,
SignalValues {
percent: Some(lang_pct),
..Default::default()
},
));
let with_hsts: usize = reports
.iter()
.filter(|r| {
r.security
.as_ref()
.is_some_and(|s| s.headers.strict_transport_security.is_some())
})
.count();
let hsts_pct = (with_hsts as f32 / total * 100.0) as u32;
signals.push(QualitySignal::graded(
HstsCoverage,
hsts_pct >= 95,
hsts_pct as f32 / 100.0,
0.15,
SignalValues {
percent: Some(hsts_pct),
..Default::default()
},
));
let pages_with_critical: usize = reports
.iter()
.filter(|r| r.accessibility.statistics.critical > 0)
.count();
let clean_pct = ((total as usize - pages_with_critical) as f32 / total * 100.0) as u32;
signals.push(QualitySignal::graded(
ErrorFreePages,
pages_with_critical == 0,
clean_pct as f32 / 100.0,
0.20,
SignalValues {
percent: Some(clean_pct),
..Default::default()
},
));
build_dimension(DimensionKind::Consistency, signals)
}
fn build_dimension(kind: DimensionKind, signals: Vec<QualitySignal>) -> DimensionScore {
if signals.is_empty() {
return DimensionScore {
kind,
name: source_quality_dimension_name(kind, true).to_string(),
score: 0,
label: source_quality_no_data_label(true),
signals: vec![],
};
}
let total_weight: f32 = signals.iter().map(|s| s.weight).sum();
let score = if total_weight > 0.0 {
let raw: f32 = signals
.iter()
.map(|s| s.weight / total_weight * 100.0 * s.fulfilment)
.sum();
raw.round() as u32
} else {
0
};
DimensionScore {
kind,
name: source_quality_dimension_name(kind, true).to_string(),
score,
label: source_quality_dimension_label(score, true),
signals,
}
}
fn average_dimensions(dims: &[DimensionScore], kind: DimensionKind) -> DimensionScore {
if dims.is_empty() {
return DimensionScore {
kind,
name: source_quality_dimension_name(kind, true).to_string(),
score: 0,
label: source_quality_no_data_label(true),
signals: vec![],
};
}
let avg = dims.iter().map(|d| d.score).sum::<u32>() / dims.len() as u32;
let signals = dims.first().map(|d| d.signals.clone()).unwrap_or_default();
DimensionScore {
kind,
name: source_quality_dimension_name(kind, true).to_string(),
score: avg,
label: source_quality_dimension_label(avg, true),
signals,
}
}
fn weighted_average(items: &[(u32, u32)]) -> u32 {
let total_weight: u32 = items.iter().map(|(_, w)| w).sum();
if total_weight == 0 {
return 0;
}
let sum: u32 = items.iter().map(|(s, w)| s * w).sum();
(sum as f64 / total_weight as f64).round() as u32
}
fn empty_analysis() -> SourceQualityAnalysis {
let empty = |kind: DimensionKind| DimensionScore {
kind,
name: source_quality_dimension_name(kind, true).to_string(),
score: 0,
label: source_quality_no_data_label(true),
signals: vec![],
};
SourceQualityAnalysis {
score: 0,
band: crate::registry::FIVE_BAND.label(0.0, true).to_string(),
substance: empty(DimensionKind::Substance),
consistency: empty(DimensionKind::Consistency),
authority: empty(DimensionKind::Authority),
disclaimer: source_quality_disclaimer(true),
}
}
#[cfg(test)]
mod tests {
#[test]
fn coverage_signals_score_proportionally_not_at_a_cliff() {
use QualitySignalKind::*;
let at = |pct: u32| {
QualitySignal::graded(
HstsCoverage,
pct >= 95,
pct as f32 / 100.0,
0.15,
SignalValues {
percent: Some(pct),
..Default::default()
},
)
};
let just_below = build_dimension(DimensionKind::Consistency, vec![at(94)]);
let none = build_dimension(DimensionKind::Consistency, vec![at(0)]);
let just_above = build_dimension(DimensionKind::Consistency, vec![at(95)]);
assert!(
just_below.score > none.score,
"94 % coverage must not score like 0 %: {} vs {}",
just_below.score,
none.score,
);
assert!(
just_above.score - just_below.score <= 2,
"the threshold must not be a cliff: {} vs {}",
just_below.score,
just_above.score,
);
assert!(!at(94).present);
assert!(at(95).present);
}
#[test]
fn binary_signals_are_unchanged() {
let met = QualitySignal::new(
QualitySignalKind::HstsCoverage,
true,
0.5,
SignalValues::default(),
);
let unmet = QualitySignal::new(
QualitySignalKind::HstsCoverage,
false,
0.5,
SignalValues::default(),
);
assert_eq!(met.fulfilment, 1.0);
assert_eq!(unmet.fulfilment, 0.0);
assert_eq!(
build_dimension(DimensionKind::Consistency, vec![met]).score,
100,
);
assert_eq!(
build_dimension(DimensionKind::Consistency, vec![unmet]).score,
0,
);
}
use super::*;
use crate::audit::normalized::InteractiveFinding;
use crate::audit::AuditReport;
use crate::audit::ViolationStatistics;
use crate::cli::WcagLevel;
use crate::taxonomy::Severity;
use crate::wcag::WcagResults;
fn minimal_report() -> AuditReport {
AuditReport {
url: "https://example.com".into(),
wcag_level: WcagLevel::AA,
timestamp: chrono::Utc::now(),
accessibility: crate::audit::AccessibilitySection {
wcag_results: WcagResults::new(),
score: 95.0,
grade: "A".into(),
certificate: "SEHR GUT".into(),
statistics: ViolationStatistics {
critical: 0,
high: 0,
medium: 0,
low: 0,
total: 0,
},
nodes_analyzed: 100,
execution: Default::default(),
},
duration_ms: 1000,
performance: None,
security: None,
html_conform: None,
experience: crate::audit::ExperienceSection::default(),
ux: None,
journey: None,
discoverability: crate::audit::DiscoverabilitySection {
seo: None,
ai_visibility: None,
content_visibility: None,
source_quality: None,
tech_stack: None,
},
page_screenshots: None,
dual_viewport: None,
viewport_scores: None,
throttled_performance: vec![],
patterns: None,
screenshot_status: Default::default(),
best_practices: None,
commerce: None,
consent_banner_detected: false,
consent_banner_cmp: None,
consent_banner_dismissed: false,
consent_privacy: None,
accessibility_journey: None,
interactive_findings: Vec::new(),
screen_reader_audit: None,
}
}
#[test]
fn test_minimal_report_produces_scores() {
let report = minimal_report();
let analysis = analyze_source_quality(&report);
assert!(analysis.score <= 100);
assert!(!analysis.disclaimer.is_empty());
assert!(!analysis.band.is_empty());
}
#[test]
fn test_empty_batch_returns_zero() {
let analysis = analyze_source_quality_batch(&[]);
assert_eq!(analysis.score, 0);
}
#[test]
fn test_batch_with_reports() {
let reports = vec![minimal_report(), minimal_report()];
let analysis = analyze_source_quality_batch(&reports);
assert!(analysis.score <= 100);
assert_eq!(analysis.consistency.name, "Consistency");
assert_eq!(analysis.consistency.kind, DimensionKind::Consistency);
}
#[test]
fn band_replaces_the_letter_grade_this_module_no_longer_carries() {
let band = |s: f32| crate::registry::FIVE_BAND.label(s, true);
assert_eq!(band(95.0), "Excellent");
assert_eq!(band(80.0), "Good");
assert_eq!(band(65.0), "Needs improvement");
assert_eq!(band(45.0), "Inadequate");
assert_eq!(band(20.0), "Critical");
}
#[test]
fn ax_missing_main_prevents_positive_landmark_signal() {
let mut report = minimal_report();
report.interactive_findings.push(InteractiveFinding {
category: "Landmark".to_string(),
kind: crate::audit::normalized::InteractiveFindingKind::LandmarkMissingMain,
maps_to_finding: None,
severity: Severity::Medium,
journey: "link_inventory".to_string(),
before_snapshot_label: None,
after_snapshot_label: None,
message: "Kein <main>-Landmark gefunden.".to_string(),
fix_suggestion: None,
values: crate::audit::normalized::InteractiveFindingValues::default(),
uncertainty: None,
});
let analysis = analyze_source_quality(&report);
let signal = analysis
.substance
.signals
.iter()
.find(|s| s.kind == QualitySignalKind::SemanticStructure)
.expect("semantic structure signal");
assert!(!signal.present);
assert!(signal.detail.contains("accessibility tree"));
let (_name, detail) =
source_quality_signal_text(signal.kind, signal.present, &signal.values, false);
assert!(detail.contains("Accessibility Tree"));
}
#[test]
fn test_weighted_average() {
assert_eq!(weighted_average(&[(100, 50), (0, 50)]), 50);
assert_eq!(weighted_average(&[(100, 100)]), 100);
assert_eq!(weighted_average(&[]), 0);
}
#[test]
fn canonical_struct_has_no_german_chars() {
use crate::seo::{
HeadingStructure, MetaTags, SeoAnalysis, SocialTags, StructuredData, TechnicalSeo,
};
let mut report = minimal_report();
report.accessibility.score = 50.0;
report.discoverability.seo = Some(SeoAnalysis {
meta: MetaTags::default(),
headings: HeadingStructure::default(),
technical: TechnicalSeo::default(),
social: SocialTags::default(),
structured_data: StructuredData::default(),
score: 40,
content_profile: None,
robots: None,
page_health: None,
serp: None,
meta_issues: vec![],
image_efficiency: None,
});
let analysis = analyze_source_quality(&report);
let dims = [
&analysis.substance,
&analysis.consistency,
&analysis.authority,
];
for dim in dims {
let mut texts = vec![dim.name.clone(), dim.label.clone()];
for s in &dim.signals {
texts.push(s.name.clone());
texts.push(s.detail.clone());
}
for t in texts {
assert!(
!t.contains(['ä', 'ö', 'ü', 'Ä', 'Ö', 'Ü', 'ß']),
"German characters in canonical struct: {t:?}"
);
}
}
assert!(!analysis
.disclaimer
.contains(['ä', 'ö', 'ü', 'Ä', 'Ö', 'Ü', 'ß']));
}
#[test]
fn signal_text_german_for_pdf_derivation() {
let (name, detail) = source_quality_signal_text(
QualitySignalKind::ContentVolume,
false,
&SignalValues {
word_count: Some(120),
..Default::default()
},
false,
);
assert_eq!(name, "Inhaltsumfang");
assert!(detail.contains("Wörter"));
assert!(detail.contains("Heuristik"));
assert_eq!(
source_quality_dimension_name(DimensionKind::Authority, false),
"Autorität"
);
assert_eq!(source_quality_dimension_label(95, false), "Sehr gut");
assert!(source_quality_disclaimer(false).contains("ausschließlich"));
}
#[test]
fn heading_structure_signal_flags_multiple_h1_as_not_well_formed() {
use crate::seo::{HeadingInfo, HeadingStructure, MetaTags, SeoAnalysis};
let mut report = minimal_report();
report.discoverability.seo = Some(SeoAnalysis {
meta: MetaTags::default(),
headings: HeadingStructure {
h1_count: 2,
h1_text: Some("First".into()),
headings: vec![
HeadingInfo {
level: 2,
text: "Intro".into(),
length: 5,
is_question: false,
in_faq_context: false,
word_count_after: 10,
},
HeadingInfo {
level: 1,
text: "First".into(),
length: 5,
is_question: false,
in_faq_context: false,
word_count_after: 10,
},
HeadingInfo {
level: 3,
text: "Sub".into(),
length: 3,
is_question: false,
in_faq_context: false,
word_count_after: 5,
},
HeadingInfo {
level: 1,
text: "Second".into(),
length: 6,
is_question: false,
in_faq_context: false,
word_count_after: 5,
},
],
issues: vec![],
total_count: 4,
},
..Default::default()
});
let analysis = analyze_source_quality(&report);
let signal = analysis
.substance
.signals
.iter()
.find(|s| s.kind == QualitySignalKind::HeadingStructure)
.expect("heading structure signal");
assert!(
!signal.present,
"two H1 headings with content before the first one must not count as well-formed"
);
assert!(
signal.detail.contains('2') && signal.detail.to_lowercase().contains("h1"),
"detail should name the actual H1 count, got {:?}",
signal.detail
);
assert!(
!signal.detail.contains("Structured outline"),
"must not claim a structured outline when H1 usage is broken: {:?}",
signal.detail
);
}
fn substance_signal(report: &AuditReport, kind: QualitySignalKind) -> QualitySignal {
analyze_source_quality(report)
.substance
.signals
.into_iter()
.find(|s| s.kind == kind)
.unwrap_or_else(|| panic!("{kind:?} signal must be present in the substance dimension"))
}
#[test]
fn content_volume_signal_requires_at_least_300_words() {
use crate::seo::{SeoAnalysis, TechnicalSeo};
let mut report = minimal_report();
report.discoverability.seo = Some(SeoAnalysis {
technical: TechnicalSeo {
word_count: 299,
..Default::default()
},
..Default::default()
});
assert!(
!substance_signal(&report, QualitySignalKind::ContentVolume).present,
"299 words must not count as sufficient content volume"
);
report.discoverability.seo = Some(SeoAnalysis {
technical: TechnicalSeo {
word_count: 300,
..Default::default()
},
..Default::default()
});
assert!(
substance_signal(&report, QualitySignalKind::ContentVolume).present,
"300 words must count as sufficient content volume"
);
}
#[test]
fn structured_data_signal_reflects_has_structured_data_flag() {
use crate::seo::SeoAnalysis;
use crate::seo::StructuredData;
let mut report = minimal_report();
report.discoverability.seo = Some(SeoAnalysis {
structured_data: StructuredData {
has_structured_data: false,
..Default::default()
},
..Default::default()
});
assert!(
!substance_signal(&report, QualitySignalKind::StructuredData).present,
"no structured data must not be flagged present"
);
report.discoverability.seo = Some(SeoAnalysis {
structured_data: StructuredData {
has_structured_data: true,
..Default::default()
},
..Default::default()
});
let signal = substance_signal(&report, QualitySignalKind::StructuredData);
assert!(
signal.present,
"has_structured_data=true must be flagged present even without JSON-LD types"
);
assert!(
signal.detail.contains("microdata/RDFa"),
"detail should explain structured data was detected via microdata/RDFa, not JSON-LD: {:?}",
signal.detail
);
}
#[test]
fn meta_description_signal_requires_at_least_50_characters() {
use crate::seo::{MetaTags, SeoAnalysis};
let mut report = minimal_report();
report.discoverability.seo = Some(SeoAnalysis {
meta: MetaTags {
description: Some("x".repeat(49)),
..Default::default()
},
..Default::default()
});
assert!(
!substance_signal(&report, QualitySignalKind::MetaDescription).present,
"a 49-character meta description must not count as meaningful"
);
report.discoverability.seo = Some(SeoAnalysis {
meta: MetaTags {
description: Some("x".repeat(50)),
..Default::default()
},
..Default::default()
});
assert!(
substance_signal(&report, QualitySignalKind::MetaDescription).present,
"a 50-character meta description must count as meaningful"
);
}
#[test]
fn language_declaration_signal_reflects_has_lang_flag() {
use crate::seo::{SeoAnalysis, TechnicalSeo};
let mut report = minimal_report();
report.discoverability.seo = Some(SeoAnalysis {
technical: TechnicalSeo {
has_lang: false,
lang: None,
..Default::default()
},
..Default::default()
});
assert!(
!substance_signal(&report, QualitySignalKind::LanguageDeclaration).present,
"missing lang attribute must not be flagged present"
);
report.discoverability.seo = Some(SeoAnalysis {
technical: TechnicalSeo {
has_lang: true,
lang: Some("de-DE".to_string()),
..Default::default()
},
..Default::default()
});
let signal = substance_signal(&report, QualitySignalKind::LanguageDeclaration);
assert!(
signal.present,
"declared lang attribute must be flagged present"
);
assert!(
signal.detail.contains("de-DE"),
"detail should name the declared language: {:?}",
signal.detail
);
}
#[test]
fn image_descriptions_signal_requires_zero_missing_alt_text_violations() {
let mut report = minimal_report();
report
.accessibility
.wcag_results
.add_violation(crate::wcag::Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"Image missing alternative text",
"img.hero",
));
assert!(
!substance_signal(&report, QualitySignalKind::ImageDescriptions).present,
"any 1.1.1 violation must not be flagged present"
);
let clean_report = minimal_report();
assert!(
substance_signal(&clean_report, QualitySignalKind::ImageDescriptions).present,
"zero 1.1.1 violations must be flagged present"
);
}
#[test]
fn semantic_structure_signal_requires_zero_landmark_violations() {
let mut report = minimal_report();
report
.accessibility
.wcag_results
.add_violation(crate::wcag::Violation::new(
"1.3.1",
"Landmark Uniqueness",
WcagLevel::A,
Severity::Medium,
"Multiple landmarks share the same accessible name",
"header.site-header",
));
let signal = substance_signal(&report, QualitySignalKind::SemanticStructure);
assert!(
!signal.present,
"a landmark-rule violation must not be flagged present"
);
assert!(
signal.detail.contains('1'),
"detail should name the actual violation count: {:?}",
signal.detail
);
let clean_report = minimal_report();
assert!(
substance_signal(&clean_report, QualitySignalKind::SemanticStructure).present,
"zero landmark violations (and no missing <main>) must be flagged present"
);
}
}