use crate::accessibility::{FocusIndicatorStatus, FocusSnapshot};
use crate::audit::normalized::{
InteractiveFinding, InteractiveFindingKind, InteractiveFindingValues, 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::new(
"HiddenFocusable",
InteractiveFindingKind::HiddenFocusableAriaHidden,
Some("a11y.aria_hidden_focus.invalid".to_string()),
Severity::High,
trace.journey.clone(),
None,
step.snapshot_label.clone(),
InteractiveFindingValues {
selector: Some(selector.clone()),
..Default::default()
},
));
} else if snap.inert_chain {
findings.push(InteractiveFinding::new(
"HiddenFocusable",
InteractiveFindingKind::HiddenFocusableInert,
Some("a11y.aria_hidden_focus.invalid".to_string()),
Severity::High,
trace.journey.clone(),
None,
step.snapshot_label.clone(),
InteractiveFindingValues {
selector: Some(selector.clone()),
..Default::default()
},
));
} else if snap.hidden_by_style {
findings.push(InteractiveFinding::new(
"HiddenFocusable",
InteractiveFindingKind::HiddenFocusableStyle,
None,
Severity::Medium,
trace.journey.clone(),
None,
step.snapshot_label.clone(),
InteractiveFindingValues {
selector: Some(selector.clone()),
..Default::default()
},
));
} else if matches!(
snap.focus_indicator,
Some(FocusIndicatorStatus::NotDetected)
) {
findings.push(InteractiveFinding::new(
"FocusIndicator",
InteractiveFindingKind::FocusIndicatorNotDetected,
None,
Severity::Medium,
trace.journey.clone(),
None,
step.snapshot_label.clone(),
InteractiveFindingValues {
selector: Some(selector.clone()),
..Default::default()
},
));
}
}
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();
vec![InteractiveFinding::new(
"TabOrder",
InteractiveFindingKind::TabOrderBackwardJumps,
None,
Severity::Medium,
trace.journey.clone(),
None,
None,
InteractiveFindingValues {
count: Some(count as u32),
examples: Some(preview),
truncated: Some(count > 3),
..Default::default()
},
)]
}
#[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("focus indicator"));
}
#[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());
}
}