use std::collections::HashSet;
use crate::i18n::I18n;
use super::navigator::{
FormControlQuality, HeadingQuality, LandmarkQuality, LinkQuality, NavigationViews,
};
use super::types::{ReadingItem, SrAuditIssue};
const ANNOUNCEMENT_DESERT_THRESHOLD: usize = 15;
const TAB_STOP_WARNING_THRESHOLD: usize = 50;
pub fn analyze_reading_sequence(
items: &[ReadingItem],
views: &NavigationViews,
detect_locale: &str,
message_en: bool,
has_disclosure_menu_pattern: bool,
) -> Vec<SrAuditIssue> {
let stopwords = localized_stopwords(detect_locale);
let en = message_en;
let mut issues = Vec::new();
detect_non_descriptive_interactive_names(items, &stopwords, en, &mut issues);
detect_duplicated_accessible_name(items, &stopwords, en, &mut issues);
detect_icon_font_contamination(items, en, &mut issues);
detect_duplicate_link_texts(views, en, &mut issues);
detect_announcement_deserts(items, en, &mut issues);
detect_skipped_heading_levels(views, en, &mut issues);
detect_heading_order_issues(views, en, &mut issues);
detect_missing_required_landmarks(views, has_disclosure_menu_pattern, en, &mut issues);
detect_unlabeled_duplicate_landmarks(views, en, &mut issues);
detect_tab_stop_count(items, en, &mut issues);
detect_empty_interactive_elements(items, en, &mut issues);
detect_empty_form_labels(views, en, &mut issues);
for issue in &mut issues {
issue.affected_node_ids.retain(|id| is_real_node_id(id));
}
issues
}
fn is_real_node_id(id: &str) -> bool {
let id = id.trim();
!id.is_empty() && !id.starts_with('-')
}
fn localized_stopwords(locale: &str) -> HashSet<String> {
I18n::new(locale)
.or_else(|_| I18n::new("de"))
.map(|i18n| {
i18n.t("linktext-generic-stopwords")
.split(',')
.map(normalize_text)
.filter(|word| !word.is_empty())
.collect()
})
.unwrap_or_default()
}
fn detect_non_descriptive_interactive_names(
items: &[ReadingItem],
stopwords: &HashSet<String>,
en: bool,
issues: &mut Vec<SrAuditIssue>,
) {
for item in items.iter().filter(|item| {
matches!(item.role.as_deref(), Some("button" | "link")) && !is_empty_name(&item.name)
}) {
let name = item.name.as_deref().unwrap_or_default();
let normalized = normalize_text(name);
if stopwords.contains(&normalized)
|| matches!(normalized.as_str(), "x" | "icon" | "bild" | "image")
{
issues.push(SrAuditIssue {
wcag_criterion: Some("2.4.4".into()),
severity: "medium".into(),
affected_node_ids: vec![item.node_id.clone()],
message: if en {
format!("Interactive name \"{name}\" is not meaningful without context.")
} else {
format!("Interaktiver Name \"{name}\" ist ohne Kontext nicht aussagekräftig.")
},
});
}
}
}
fn detect_duplicated_accessible_name(
items: &[ReadingItem],
stopwords: &HashSet<String>,
en: bool,
issues: &mut Vec<SrAuditIssue>,
) {
for item in items.iter().filter(|item| {
matches!(item.role.as_deref(), Some("button" | "link")) && !is_empty_name(&item.name)
}) {
let name = item.name.as_deref().unwrap_or_default();
let Some(half) = duplicated_half(name) else {
continue;
};
let normalized_half = normalize_text(&half);
if normalized_half.chars().count() < 2 || stopwords.contains(&normalized_half) {
continue;
}
issues.push(SrAuditIssue {
wcag_criterion: Some("2.4.4".into()),
severity: "medium".into(),
affected_node_ids: vec![item.node_id.clone()],
message: if en {
format!(
"Accessible name \"{name}\" repeats \"{half}\" twice in a row. \
A screen reader announces it as \"{name}\", which sounds like a glitch."
)
} else {
format!(
"Zugänglicher Name \"{name}\" wiederholt \"{half}\" zweimal hintereinander. \
Ein Screenreader kündigt ihn als \"{name}\" an, was wie ein Fehler wirkt."
)
},
});
}
}
fn duplicated_half(name: &str) -> Option<String> {
let words: Vec<&str> = name.split_whitespace().collect();
if words.len() < 2 || !words.len().is_multiple_of(2) {
return None;
}
let mid = words.len() / 2;
let (first, second) = words.split_at(mid);
let is_duplicate = first
.iter()
.zip(second.iter())
.all(|(a, b)| a.to_lowercase() == b.to_lowercase());
is_duplicate.then(|| first.join(" "))
}
fn detect_duplicate_link_texts(views: &NavigationViews, en: bool, issues: &mut Vec<SrAuditIssue>) {
for link in views.links.iter().filter(|link| link.count > 1) {
let quality = match (link.quality, en) {
(LinkQuality::Empty, true) => "Empty link text",
(LinkQuality::Empty, false) => "Leerer Linktext",
(LinkQuality::NonDescriptive | LinkQuality::ContextDependent, true) => {
"Context-dependent link text"
}
(LinkQuality::NonDescriptive | LinkQuality::ContextDependent, false) => {
"Kontextabhängiger Linktext"
}
(LinkQuality::Good, _) => continue,
};
issues.push(SrAuditIssue {
wcag_criterion: Some("2.4.4".into()),
severity: "low".into(),
affected_node_ids: link.node_ids.clone(),
message: if en {
format!(
"{} occurs {} times and clutters the screen reader's link list.",
quality, link.count
)
} else {
format!(
"{} kommt {} mal vor und erschwert die Linkliste im Screenreader.",
quality, link.count
)
},
});
}
}
fn detect_announcement_deserts(items: &[ReadingItem], en: bool, issues: &mut Vec<SrAuditIssue>) {
let mut segment_start = 0usize;
let mut count = 0usize;
let mut node_ids = Vec::new();
for item in items {
if is_orientation_item(item) {
if count > ANNOUNCEMENT_DESERT_THRESHOLD {
push_desert_issue(segment_start, count, &node_ids, en, issues);
}
segment_start = item.seq + 1;
count = 0;
node_ids.clear();
} else {
count += 1;
node_ids.push(item.node_id.clone());
}
}
if count > ANNOUNCEMENT_DESERT_THRESHOLD {
push_desert_issue(segment_start, count, &node_ids, en, issues);
}
}
fn push_desert_issue(
segment_start: usize,
count: usize,
node_ids: &[String],
en: bool,
issues: &mut Vec<SrAuditIssue>,
) {
issues.push(SrAuditIssue {
wcag_criterion: Some("1.3.6".into()),
severity: "low".into(),
affected_node_ids: node_ids.to_vec(),
message: if en {
format!(
"Long section from sequence position {} without a landmark, heading or focus target ({} entries).",
segment_start, count
)
} else {
format!(
"Langer Abschnitt ab Sequenzposition {} ohne Landmark, Überschrift oder Fokusziel ({} Einträge).",
segment_start, count
)
},
});
}
fn detect_skipped_heading_levels(
views: &NavigationViews,
en: bool,
issues: &mut Vec<SrAuditIssue>,
) {
for heading in views
.headings
.iter()
.filter(|heading| heading.quality == HeadingQuality::SkippedLevel)
{
let level = heading.level.map(|l| format!("h{l}")).unwrap_or_else(|| {
if en {
"an unknown level".into()
} else {
"einer unbekannten Ebene".into()
}
});
issues.push(SrAuditIssue {
wcag_criterion: Some("1.3.1".into()),
severity: "medium".into(),
affected_node_ids: vec![heading.node_id.clone()],
message: if en {
format!(
"Heading level is skipped: {level} at sequence position {}.",
heading.seq
)
} else {
format!(
"Überschriftenebene wird übersprungen: {level} an Sequenzposition {}.",
heading.seq
)
},
});
}
}
fn detect_unlabeled_duplicate_landmarks(
views: &NavigationViews,
en: bool,
issues: &mut Vec<SrAuditIssue>,
) {
let affected: Vec<String> = views
.landmarks
.iter()
.filter(|landmark| landmark.quality == LandmarkQuality::UnlabeledDuplicate)
.map(|landmark| landmark.node_id.clone())
.collect();
if affected.len() > 1 {
issues.push(SrAuditIssue {
wcag_criterion: Some("1.3.1".into()),
severity: "medium".into(),
affected_node_ids: affected,
message: if en {
"Several landmarks of the same type without a name are indistinguishable in the screen reader's landmark list.".into()
} else {
"Mehrere gleichartige Landmarken ohne Namen sind in der Screenreader-Landmarkliste nicht unterscheidbar.".into()
},
});
}
for landmark in views
.landmarks
.iter()
.filter(|landmark| landmark.quality == LandmarkQuality::MissingMain)
{
issues.push(SrAuditIssue {
wcag_criterion: Some("1.3.1".into()),
severity: "medium".into(),
affected_node_ids: vec![landmark.node_id.clone()],
message: if en {
"No main landmark detectable in the screen reader's landmark list.".into()
} else {
"Kein Main-Landmark in der Screenreader-Landmarkliste erkennbar.".into()
},
});
}
}
fn detect_missing_required_landmarks(
views: &NavigationViews,
has_disclosure_menu_pattern: bool,
en: bool,
issues: &mut Vec<SrAuditIssue>,
) {
const REQUIRED_EN: &[(&str, &str)] = &[
(
"banner",
"No header area (banner landmark) present. Screen readers cannot fully navigate the page structure.",
),
(
"navigation",
"No navigation landmark present. Keyboard users cannot jump directly to the navigation.",
),
(
"contentinfo",
"No footer landmark (contentinfo) present. The page structure is incomplete for screen readers.",
),
];
const REQUIRED_DE: &[(&str, &str)] = &[
(
"banner",
"Kein Header-Bereich (banner-Landmark) vorhanden. Screen Reader können die Seitenstruktur nicht vollständig navigieren.",
),
(
"navigation",
"Keine Navigations-Landmark vorhanden. Tastaturnutzer können nicht direkt zur Navigation springen.",
),
(
"contentinfo",
"Keine Fußzeilen-Landmark (contentinfo) vorhanden. Die Seitenstruktur ist für Screen Reader unvollständig.",
),
];
let required = if en { REQUIRED_EN } else { REQUIRED_DE };
for (role, message) in required {
let present = views
.landmarks
.iter()
.any(|l| l.role == *role && l.quality != LandmarkQuality::MissingMain);
if !present {
if *role == "navigation" && has_disclosure_menu_pattern {
issues.push(SrAuditIssue {
wcag_criterion: Some("1.3.6".into()),
severity: "low".into(),
affected_node_ids: vec![],
message: navigation_behind_disclosure_message(en),
});
continue;
}
issues.push(SrAuditIssue {
wcag_criterion: Some("1.3.6".into()),
severity: "medium".into(),
affected_node_ids: vec![],
message: message.to_string(),
});
}
}
}
fn navigation_behind_disclosure_message(en: bool) -> String {
if en {
"No navigation landmark present in the captured accessibility tree, but a disclosure/menu \
trigger was recognized — the navigation is likely collapsed rather than missing. Confirm \
it becomes reachable once expanded."
.to_string()
} else {
"Keine Navigations-Landmark im erfassten Accessibility-Tree vorhanden, aber ein Ausklapp-/\
Menü-Element wurde erkannt — die Navigation ist vermutlich eingeklappt statt fehlend. \
Prüfen, ob sie nach dem Ausklappen erreichbar ist."
.to_string()
}
}
fn detect_icon_font_contamination(items: &[ReadingItem], en: bool, issues: &mut Vec<SrAuditIssue>) {
let affected: Vec<String> = items
.iter()
.filter(|item| {
matches!(item.role.as_deref(), Some("button" | "link"))
&& item.name.as_deref().is_some_and(contains_pua)
})
.map(|item| item.node_id.clone())
.collect();
if !affected.is_empty() {
issues.push(SrAuditIssue {
wcag_criterion: Some("2.4.4".into()),
severity: "medium".into(),
affected_node_ids: affected,
message: if en {
"Link or button name contains icon-font characters (Unicode Private Use Area). Screen readers read these out as cryptic character codes.".into()
} else {
"Link- oder Button-Name enthält Icon-Font-Zeichen (Unicode Private Use Area). Screen Reader lesen diese als kryptische Zeichencodes vor.".into()
},
});
}
}
fn contains_pua(text: &str) -> bool {
text.chars().any(|c| ('\u{E000}'..='\u{F8FF}').contains(&c))
}
fn detect_heading_order_issues(views: &NavigationViews, en: bool, issues: &mut Vec<SrAuditIssue>) {
let headings = &views.headings;
if let Some(first) = headings.iter().find(|h| h.quality != HeadingQuality::Empty) {
if first.level != Some(1) {
issues.push(SrAuditIssue {
wcag_criterion: Some("1.3.1".into()),
severity: "medium".into(),
affected_node_ids: vec![first.node_id.clone()],
message: if en {
format!(
"First heading in the document is H{} instead of H1 (sequence position {}).",
first.level.unwrap_or(0),
first.seq
)
} else {
format!(
"Erste Überschrift im Dokument ist H{} statt H1 (Sequenzposition {}).",
first.level.unwrap_or(0),
first.seq
)
},
});
}
}
let first_sub_seq = headings
.iter()
.find(|h| h.level.map(|l| l >= 2).unwrap_or(false))
.map(|h| h.seq);
let first_h1 = headings.iter().find(|h| h.level == Some(1));
if let (Some(sub_seq), Some(h1)) = (first_sub_seq, first_h1) {
if sub_seq < h1.seq {
issues.push(SrAuditIssue {
wcag_criterion: Some("1.3.1".into()),
severity: "medium".into(),
affected_node_ids: vec![h1.node_id.clone()],
message: if en {
format!(
"H1 first appears at sequence position {} — after an H2/H3 heading.",
h1.seq
)
} else {
format!(
"H1 erscheint erst an Sequenzposition {} — nach einer H2/H3-Überschrift.",
h1.seq
)
},
});
}
}
}
fn detect_tab_stop_count(items: &[ReadingItem], en: bool, issues: &mut Vec<SrAuditIssue>) {
let tab_stop_count = items.iter().filter(|item| item.tab_stop).count();
let has_skip_link = items.iter().any(|item| {
item.role.as_deref() == Some("link")
&& item
.name
.as_deref()
.is_some_and(crate::patterns::skip_link::is_skip_link_text)
});
if tab_stop_count > TAB_STOP_WARNING_THRESHOLD && !has_skip_link {
issues.push(SrAuditIssue {
wcag_criterion: Some("2.4.1".into()),
severity: "medium".into(),
affected_node_ids: items
.iter()
.filter(|item| item.tab_stop)
.map(|item| item.node_id.clone())
.collect(),
message: if en {
format!(
"{} tab stops without a detectable skip link hamper keyboard and screen reader navigation.",
tab_stop_count
)
} else {
format!(
"{} Tab-Stops ohne erkennbaren Skip-Link erschweren Tastatur- und Screenreader-Navigation.",
tab_stop_count
)
},
});
}
}
fn detect_empty_interactive_elements(
items: &[ReadingItem],
en: bool,
issues: &mut Vec<SrAuditIssue>,
) {
let affected: Vec<String> = items
.iter()
.filter(|item| {
matches!(item.role.as_deref(), Some("button" | "link")) && is_empty_name(&item.name)
})
.map(|item| item.node_id.clone())
.collect();
if !affected.is_empty() {
issues.push(SrAuditIssue {
wcag_criterion: Some("4.1.2".into()),
severity: "high".into(),
affected_node_ids: affected,
message: if en {
"Interactive elements without an accessible name are not announced intelligibly by a screen reader.".into()
} else {
"Interaktive Elemente ohne zugänglichen Namen werden im Screenreader nicht verständlich angekündigt.".into()
},
});
}
}
fn detect_empty_form_labels(views: &NavigationViews, en: bool, issues: &mut Vec<SrAuditIssue>) {
let affected: Vec<String> = views
.form_controls
.iter()
.filter(|control| control.quality == FormControlQuality::EmptyLabel)
.map(|control| control.node_id.clone())
.collect();
if !affected.is_empty() {
issues.push(SrAuditIssue {
wcag_criterion: Some("3.3.2".into()),
severity: "high".into(),
affected_node_ids: affected,
message: if en {
"Form fields without a label are not intelligible in the screen reader's form list.".into()
} else {
"Formularfelder ohne Label sind in der Screenreader-Formularliste nicht verständlich.".into()
},
});
}
}
fn is_orientation_item(item: &ReadingItem) -> bool {
item.tab_stop
|| matches!(
item.role.as_deref(),
Some(
"heading"
| "banner"
| "navigation"
| "main"
| "contentinfo"
| "complementary"
| "search"
| "region"
| "form"
)
)
}
fn is_empty_name(name: &Option<String>) -> bool {
name.as_deref().is_none_or(|name| name.trim().is_empty())
}
fn normalize_text(text: &str) -> String {
text.trim().to_lowercase()
}
pub fn name_quality_score(items: &[ReadingItem], detect_locale: &str) -> u32 {
let stopwords = localized_stopwords(detect_locale);
let interactive: Vec<_> = items
.iter()
.filter(|item| item.tab_stop || matches!(item.role.as_deref(), Some("button" | "link")))
.collect();
if interactive.is_empty() {
return 100;
}
let good = interactive
.iter()
.filter(|item| {
let Some(name) = item
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
else {
return false;
};
!stopwords.contains(&normalize_text(name))
&& !matches!(
normalize_text(name).as_str(),
"x" | "icon" | "bild" | "image"
)
})
.count();
((good as f64 / interactive.len() as f64) * 100.0).round() as u32
}
#[cfg(test)]
mod tests {
use super::{analyze_reading_sequence, name_quality_score};
use crate::screen_reader::{navigation_views, ReadingItem};
fn item(
seq: usize,
role: &str,
name: Option<&str>,
tab_stop: bool,
states: Vec<&str>,
) -> ReadingItem {
ReadingItem {
seq,
role: Some(role.to_string()),
name: name.map(String::from),
description: None,
value: None,
states: states.into_iter().map(String::from).collect(),
tab_stop,
depth: 0,
node_id: format!("node-{seq}"),
}
}
#[test]
fn detects_non_descriptive_and_empty_interactive_names() {
let items = vec![
item(0, "link", Some("Hier"), true, vec![]),
item(1, "button", None, true, vec![]),
item(2, "button", Some("Menü öffnen"), true, vec![]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
assert!(issues
.iter()
.any(|issue| issue.wcag_criterion.as_deref() == Some("2.4.4")));
assert!(issues
.iter()
.any(|issue| issue.wcag_criterion.as_deref() == Some("4.1.2")));
assert_eq!(name_quality_score(&items, "de"), 33);
}
#[test]
fn detects_skipped_heading_and_duplicate_landmarks() {
let items = vec![
item(0, "navigation", None, false, vec![]),
item(1, "main", Some("Inhalt"), false, vec![]),
item(2, "navigation", None, false, vec![]),
item(3, "heading", Some("Start"), false, vec!["level=1"]),
item(4, "heading", Some("Deep"), false, vec!["level=3"]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
assert!(issues
.iter()
.any(|issue| issue.message.contains("Landmarken")));
assert!(issues
.iter()
.any(|issue| issue.message.contains("Überschriftenebene")));
}
#[test]
fn skipped_heading_level_is_tagged_1_3_1_not_2_4_6_and_has_no_debug_leak() {
let items = vec![
item(0, "main", Some("Inhalt"), false, vec![]),
item(1, "heading", Some("Start"), false, vec!["level=1"]),
item(2, "heading", Some("Deep"), false, vec!["level=3"]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", true, false);
let skipped = issues
.iter()
.find(|issue| issue.message.contains("skipped"))
.expect("expected a skipped-heading-level issue");
assert_eq!(skipped.wcag_criterion.as_deref(), Some("1.3.1"));
assert!(
!skipped.message.contains("Some("),
"message leaks Option debug format: {}",
skipped.message
);
assert!(skipped.message.contains("h3"));
}
#[test]
fn does_not_flag_icon_button_with_accessible_name() {
let items = vec![
item(0, "banner", Some("Header"), false, vec![]),
item(1, "navigation", Some("Nav"), false, vec![]),
item(2, "main", Some("Inhalt"), false, vec![]),
item(3, "contentinfo", Some("Footer"), false, vec![]),
item(4, "button", Some("Suche öffnen"), true, vec![]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
assert!(issues.is_empty());
}
#[test]
fn english_locale_messages_carry_no_german_umlauts() {
let mut items = vec![
item(0, "link", Some("Hier"), true, vec![]),
item(1, "button", None, true, vec![]),
item(2, "main", Some("Content"), false, vec!["level=3"]),
item(3, "heading", Some("Deep"), false, vec!["level=3"]),
];
for seq in 4..40 {
items.push(item(
seq,
"link",
Some(&format!("Link {seq}")),
true,
vec![],
));
}
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "en", true, false);
assert!(!issues.is_empty(), "scenario should produce issues");
for issue in &issues {
assert!(
!issue.message.chars().any(|c| "äöüÄÖÜß".contains(c)),
"EN message contains German umlaut: {}",
issue.message
);
}
}
#[test]
fn recognizes_idiomatic_german_skip_link_phrasing_not_just_ueberspring() {
let mut items = vec![item(0, "link", Some("Zum Inhalt springen"), true, vec![])];
for seq in 1..60 {
items.push(item(
seq,
"link",
Some(&format!("Link {seq}")),
true,
vec![],
));
}
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
let tab_stop_issues: Vec<_> = issues
.iter()
.filter(|i| i.wcag_criterion.as_deref() == Some("2.4.1"))
.collect();
assert!(
tab_stop_issues.is_empty(),
"a real skip link phrased as 'Zum Inhalt springen' must suppress the \
tab-stop warning; got {tab_stop_issues:?}"
);
}
#[test]
fn detects_missing_required_landmarks() {
let items = vec![item(0, "main", Some("Inhalt"), false, vec![])];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
let landmark_issues: Vec<_> = issues
.iter()
.filter(|i| i.wcag_criterion.as_deref() == Some("1.3.6"))
.collect();
assert_eq!(
landmark_issues.len(),
3,
"expected issues for banner, navigation, contentinfo"
);
}
#[test]
fn downgrades_missing_navigation_when_disclosure_menu_pattern_recognized() {
let items = vec![item(0, "main", Some("Inhalt"), false, vec![])];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, true);
let nav_issue = issues
.iter()
.find(|i| i.wcag_criterion.as_deref() == Some("1.3.6") && i.message.contains("Menü"))
.expect("expected a downgraded navigation issue");
assert_eq!(nav_issue.severity, "low");
assert!(issues
.iter()
.any(|i| i.wcag_criterion.as_deref() == Some("1.3.6") && i.severity == "medium"));
}
#[test]
fn no_landmark_issues_when_all_required_present() {
let items = vec![
item(0, "banner", Some("Header"), false, vec![]),
item(1, "navigation", Some("Main nav"), false, vec![]),
item(2, "main", Some("Inhalt"), false, vec![]),
item(3, "contentinfo", Some("Footer"), false, vec![]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
assert!(!issues
.iter()
.any(|i| i.wcag_criterion.as_deref() == Some("1.3.6")));
}
#[test]
fn detects_icon_font_pua_in_link_name() {
let items = vec![
item(0, "main", Some("Inhalt"), false, vec![]),
item(1, "link", Some("\u{E003}"), true, vec![]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
assert!(issues.iter().any(|i| i.message.contains("Icon-Font")));
}
#[test]
fn does_not_flag_link_with_normal_text() {
let items = vec![
item(0, "main", Some("Inhalt"), false, vec![]),
item(1, "link", Some("Kontakt"), true, vec![]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
assert!(!issues.iter().any(|i| i.message.contains("Icon-Font")));
}
#[test]
fn detects_h2_before_h1() {
let items = vec![
item(0, "banner", Some("Header"), false, vec![]),
item(1, "navigation", Some("Nav"), false, vec![]),
item(2, "main", Some("Inhalt"), false, vec![]),
item(3, "contentinfo", Some("Footer"), false, vec![]),
item(57, "heading", Some("Sub"), false, vec!["level=2"]),
item(128, "heading", Some("Title"), false, vec!["level=1"]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
let order_issues: Vec<_> = issues
.iter()
.filter(|i| i.wcag_criterion.as_deref() == Some("1.3.1"))
.collect();
assert!(
order_issues.len() >= 2,
"expected heading-order issues, got: {:?}",
order_issues.iter().map(|i| &i.message).collect::<Vec<_>>()
);
}
#[test]
fn no_heading_order_issue_when_h1_is_first() {
let items = vec![
item(0, "banner", Some("Header"), false, vec![]),
item(1, "navigation", Some("Nav"), false, vec![]),
item(2, "main", Some("Inhalt"), false, vec![]),
item(3, "contentinfo", Some("Footer"), false, vec![]),
item(10, "heading", Some("Title"), false, vec!["level=1"]),
item(20, "heading", Some("Sub"), false, vec!["level=2"]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
assert!(!issues.iter().any(|i| {
i.wcag_criterion.as_deref() == Some("1.3.1")
&& (i.message.contains("erste Überschrift") || i.message.contains("H1 erscheint"))
}));
}
#[test]
fn detects_duplicated_accessible_name_on_footer_style_link() {
let items = vec![
item(0, "banner", Some("Header"), false, vec![]),
item(1, "navigation", Some("Nav"), false, vec![]),
item(2, "main", Some("Inhalt"), false, vec![]),
item(3, "contentinfo", Some("Footer"), false, vec![]),
item(4, "link", Some("Kontakt Kontakt"), true, vec![]),
item(5, "link", Some("AGB AGB"), true, vec![]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
let duplicated: Vec<_> = issues
.iter()
.filter(|i| i.message.contains("wiederholt"))
.collect();
assert_eq!(
duplicated.len(),
2,
"expected a duplicated-name issue for both links, got: {:?}",
issues.iter().map(|i| &i.message).collect::<Vec<_>>()
);
assert!(duplicated
.iter()
.all(|i| i.wcag_criterion.as_deref() == Some("2.4.4")));
assert!(duplicated
.iter()
.any(|i| i.affected_node_ids == vec!["node-4".to_string()]));
assert!(duplicated
.iter()
.any(|i| i.affected_node_ids == vec!["node-5".to_string()]));
}
#[test]
fn does_not_flag_generic_stopword_repeated_as_duplicated_name() {
let items = vec![
item(0, "main", Some("Inhalt"), false, vec![]),
item(1, "link", Some("Mehr Mehr"), true, vec![]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
assert!(!issues.iter().any(|i| i.message.contains("wiederholt")));
}
#[test]
fn does_not_flag_non_duplicate_name_with_shared_prefix() {
let items = vec![
item(0, "main", Some("Inhalt"), false, vec![]),
item(1, "link", Some("Kontakt Kontaktformular"), true, vec![]),
];
let views = navigation_views(&items);
let issues = analyze_reading_sequence(&items, &views, "de", false, false);
assert!(!issues.iter().any(|i| i.message.contains("wiederholt")));
}
}