use crate::accessibility::{FocusIndicatorStatus, FocusSnapshot};
use crate::audit::normalized::{InteractiveFinding, JourneyTrace};
use crate::taxonomy::Severity;
pub fn tab_walk(trace: &JourneyTrace, snapshots: &[FocusSnapshot]) -> Vec<InteractiveFinding> {
let mut findings = Vec::new();
for (step, snap) in trace.steps.iter().zip(snapshots.iter()) {
if step.action != "tab" {
continue;
}
let Some(selector) = &step.focus else {
continue;
};
if snap.aria_hidden_chain {
findings.push(InteractiveFinding {
category: "HiddenFocusable".to_string(),
maps_to_finding: Some("a11y.aria_hidden_focus.invalid".to_string()),
severity: Severity::High,
journey: trace.journey.clone(),
before_snapshot_label: None,
after_snapshot_label: step.snapshot_label.clone(),
message: format!(
"Tastatur-Fokus landet auf einem Element innerhalb \
eines aria-hidden-Bereichs ({selector}). Screenreader-Nutzer \
erreichen ein vom AXTree ausgeblendetes Element."
),
fix_suggestion: Some(
"Element aus aria-hidden-Bereich entfernen oder \
tabindex=\"-1\" setzen."
.to_string(),
),
});
} else if snap.inert_chain {
findings.push(InteractiveFinding {
category: "HiddenFocusable".to_string(),
maps_to_finding: Some("a11y.aria_hidden_focus.invalid".to_string()),
severity: Severity::High,
journey: trace.journey.clone(),
before_snapshot_label: None,
after_snapshot_label: step.snapshot_label.clone(),
message: format!(
"Tastatur-Fokus landet auf einem Element innerhalb \
eines inert-Bereichs ({selector}). Inert-Bereiche \
sollten nicht erreichbar sein."
),
fix_suggestion: Some(
"Element aus inert-Bereich nehmen oder \
tabindex/Fokus-Kette korrigieren."
.to_string(),
),
});
} else if snap.hidden_by_style {
findings.push(InteractiveFinding {
category: "HiddenFocusable".to_string(),
maps_to_finding: None,
severity: Severity::Medium,
journey: trace.journey.clone(),
before_snapshot_label: None,
after_snapshot_label: step.snapshot_label.clone(),
message: format!(
"Tastatur-Fokus landet auf einem visuell verborgenen \
Element ({selector}: display:none, visibility:hidden \
oder opacity:0). Tastatur-Nutzer verlieren die \
Orientierung."
),
fix_suggestion: Some(
"Element aus Tab-Sequenz entfernen (tabindex=\"-1\") \
oder erst sichtbar machen."
.to_string(),
),
});
} else if matches!(
snap.focus_indicator,
Some(FocusIndicatorStatus::NotDetected)
) {
findings.push(InteractiveFinding {
category: "FocusIndicator".to_string(),
maps_to_finding: None,
severity: Severity::Medium,
journey: trace.journey.clone(),
before_snapshot_label: None,
after_snapshot_label: step.snapshot_label.clone(),
message: format!(
"Element ({selector}) zeigt im fokussierten Zustand keinen \
sichtbaren Fokus-Indikator (kein outline, kein box-shadow, \
keine border-Änderung). Tastatur-Nutzer verlieren die \
Orientierung."
),
fix_suggestion: Some(
"CSS :focus-visible-Regel ergänzen mit klarer outline-, \
box-shadow- oder border-Änderung gegenüber dem unfokussierten \
Zustand."
.to_string(),
),
});
}
}
findings
}
pub fn tab_walk_order(trace: &JourneyTrace, dom_order: &[String]) -> Vec<InteractiveFinding> {
if dom_order.is_empty() {
return Vec::new();
}
let dom_index =
|selector: &str| -> Option<usize> { dom_order.iter().position(|s| s == selector) };
let mut last_dom_index: Option<usize> = None;
let mut reverse_jumps: Vec<String> = Vec::new();
for step in &trace.steps {
if step.action != "tab" {
continue;
}
let Some(selector) = &step.focus else {
continue;
};
let Some(idx) = dom_index(selector) else {
continue;
};
if let Some(prev) = last_dom_index {
if idx < prev {
reverse_jumps.push(selector.clone());
}
}
last_dom_index = Some(idx);
}
if reverse_jumps.is_empty() {
return Vec::new();
}
let preview = reverse_jumps
.iter()
.take(3)
.cloned()
.collect::<Vec<_>>()
.join(", ");
let count = reverse_jumps.len();
let suffix = if count > 3 { " (…)" } else { "" };
vec![InteractiveFinding {
category: "TabOrder".to_string(),
maps_to_finding: None,
severity: Severity::Medium,
journey: trace.journey.clone(),
before_snapshot_label: None,
after_snapshot_label: None,
message: format!(
"Tab-Reihenfolge weicht von der DOM-Reihenfolge ab: {count} Rückwärts-Sprung(e) \
beobachtet. Erste betroffene Elemente: {preview}{suffix}. \
Tastatur-Nutzer könnten dem Lesefluss nicht folgen."
),
fix_suggestion: Some(
"Negative oder hohe tabindex-Werte vermeiden. \
Lese-/DOM-Reihenfolge so anordnen, dass sie der visuellen \
Reihenfolge entspricht."
.to_string(),
),
}]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::normalized::JourneyStep;
fn step_tab(selector: &str, label: &str) -> JourneyStep {
JourneyStep {
action: "tab".to_string(),
target: None,
focus: Some(selector.to_string()),
result: None,
snapshot_label: Some(label.to_string()),
}
}
fn snap_with(aria: bool, inert: bool, hidden: bool) -> FocusSnapshot {
snap_full(aria, inert, hidden, None)
}
fn snap_full(
aria: bool,
inert: bool,
hidden: bool,
indicator: Option<FocusIndicatorStatus>,
) -> FocusSnapshot {
FocusSnapshot {
selector: Some("a".to_string()),
aria_hidden_chain: aria,
inert_chain: inert,
hidden_by_style: hidden,
focus_indicator: indicator,
..Default::default()
}
}
fn trace_with(steps: Vec<JourneyStep>) -> JourneyTrace {
JourneyTrace {
journey: "tab_walk".to_string(),
steps,
}
}
#[test]
fn clean_walk_produces_no_findings() {
let trace = trace_with(vec![step_tab("a#one", "after_tab_1")]);
let snaps = vec![snap_with(false, false, false)];
assert!(tab_walk(&trace, &snaps).is_empty());
}
#[test]
fn aria_hidden_chain_emits_high_finding() {
let trace = trace_with(vec![step_tab("a#one", "after_tab_1")]);
let snaps = vec![snap_with(true, false, false)];
let findings = tab_walk(&trace, &snaps);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].category, "HiddenFocusable");
assert_eq!(findings[0].severity, Severity::High);
assert!(findings[0].message.contains("aria-hidden"));
}
#[test]
fn inert_chain_emits_high_finding() {
let trace = trace_with(vec![step_tab("a#one", "after_tab_1")]);
let snaps = vec![snap_with(false, true, false)];
let findings = tab_walk(&trace, &snaps);
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("inert"));
}
#[test]
fn hidden_by_style_emits_medium_finding() {
let trace = trace_with(vec![step_tab("a#one", "after_tab_1")]);
let snaps = vec![snap_with(false, false, true)];
let findings = tab_walk(&trace, &snaps);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Medium);
}
#[test]
fn aria_hidden_takes_precedence_over_inert() {
let trace = trace_with(vec![step_tab("a#one", "after_tab_1")]);
let snaps = vec![snap_with(true, true, true)];
let findings = tab_walk(&trace, &snaps);
assert_eq!(findings.len(), 1);
assert!(findings[0].message.contains("aria-hidden"));
}
#[test]
fn start_step_is_ignored() {
let mut trace = trace_with(vec![]);
trace.steps.push(JourneyStep {
action: "start".to_string(),
target: None,
focus: Some("body".to_string()),
result: None,
snapshot_label: Some("initial".to_string()),
});
let snaps = vec![snap_with(true, false, false)];
assert!(tab_walk(&trace, &snaps).is_empty());
}
#[test]
fn focus_indicator_not_detected_emits_finding() {
let trace = trace_with(vec![step_tab("a#one", "after_tab_1")]);
let snaps = vec![snap_full(
false,
false,
false,
Some(FocusIndicatorStatus::NotDetected),
)];
let findings = tab_walk(&trace, &snaps);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].category, "FocusIndicator");
assert_eq!(findings[0].severity, Severity::Medium);
assert!(findings[0].message.contains("Fokus-Indikator"));
}
#[test]
fn focus_indicator_detected_emits_no_finding() {
let trace = trace_with(vec![step_tab("a#one", "after_tab_1")]);
let snaps = vec![snap_full(
false,
false,
false,
Some(FocusIndicatorStatus::Detected),
)];
assert!(tab_walk(&trace, &snaps).is_empty());
}
#[test]
fn focus_indicator_ambiguous_emits_no_finding() {
let trace = trace_with(vec![step_tab("a#one", "after_tab_1")]);
let snaps = vec![snap_full(
false,
false,
false,
Some(FocusIndicatorStatus::Ambiguous),
)];
assert!(tab_walk(&trace, &snaps).is_empty());
}
#[test]
fn hidden_focusable_takes_precedence_over_indicator() {
let trace = trace_with(vec![step_tab("a#one", "after_tab_1")]);
let snaps = vec![snap_full(
true,
false,
false,
Some(FocusIndicatorStatus::NotDetected),
)];
let findings = tab_walk(&trace, &snaps);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].category, "HiddenFocusable");
assert_eq!(
findings[0].maps_to_finding.as_deref(),
Some("a11y.aria_hidden_focus.invalid")
);
}
#[test]
fn forward_walk_produces_no_order_finding() {
let trace = trace_with(vec![
step_tab("a", "after_tab_1"),
step_tab("b", "after_tab_2"),
step_tab("c", "after_tab_3"),
]);
let dom = vec!["a".to_string(), "b".to_string(), "c".to_string()];
assert!(tab_walk_order(&trace, &dom).is_empty());
}
#[test]
fn reverse_jump_emits_warning() {
let trace = trace_with(vec![
step_tab("a", "after_tab_1"),
step_tab("c", "after_tab_2"),
step_tab("b", "after_tab_3"),
]);
let dom = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let findings = tab_walk_order(&trace, &dom);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].category, "TabOrder");
assert_eq!(findings[0].severity, Severity::Medium);
assert!(findings[0].message.contains("b"));
}
#[test]
fn forward_gap_is_not_a_finding() {
let trace = trace_with(vec![
step_tab("a", "after_tab_1"),
step_tab("c", "after_tab_2"),
]);
let dom = vec!["a".to_string(), "b".to_string(), "c".to_string()];
assert!(tab_walk_order(&trace, &dom).is_empty());
}
#[test]
fn empty_dom_order_produces_no_finding() {
let trace = trace_with(vec![step_tab("a", "after_tab_1")]);
assert!(tab_walk_order(&trace, &[]).is_empty());
}
#[test]
fn unknown_selector_does_not_panic() {
let trace = trace_with(vec![
step_tab("a", "after_tab_1"),
step_tab("z", "after_tab_2"),
]);
let dom = vec!["a".to_string(), "b".to_string()];
assert!(tab_walk_order(&trace, &dom).is_empty());
}
}