use std::time::Instant;
use chromiumoxide::Page;
use crate::accessibility::{AXSnapshot, AXTreeDiff};
use crate::audit::normalized::{
InteractiveFinding, InteractiveFindingKind, InteractiveFindingValues, JourneyStep, JourneyTrace,
};
use crate::error::Result;
use crate::interaction::{pointer, stability};
use crate::patterns::JourneyCandidate;
use crate::taxonomy::Severity;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Verdict {
StateAndContent,
StateOnly,
ContentOnly,
NoChange,
WrongDirection,
NotObservable,
}
impl Verdict {
fn as_str(self) -> &'static str {
match self {
Verdict::StateAndContent => "diff:state+content",
Verdict::StateOnly => "diff:state_only",
Verdict::ContentOnly => "diff:content_only",
Verdict::NoChange => "diff:no_change",
Verdict::WrongDirection => "diff:state_wrong_direction",
Verdict::NotObservable => "diff:not_observable",
}
}
}
fn outcome(
verdict: Verdict,
want_open: bool,
) -> (Option<(InteractiveFindingKind, Severity)>, bool) {
match verdict {
Verdict::StateAndContent => (None, true),
Verdict::StateOnly => (
Some((
InteractiveFindingKind::DisclosureStateWithoutContent,
Severity::Medium,
)),
true,
),
Verdict::ContentOnly => (
Some((
InteractiveFindingKind::DisclosureContentWithoutState,
Severity::Medium,
)),
false,
),
Verdict::NoChange => (Some(missed_state_kind(want_open)), false),
Verdict::WrongDirection | Verdict::NotObservable => (None, false),
}
}
fn diff_verdict(diff: Option<&AXTreeDiff>, trigger: i64, want: bool) -> Verdict {
let Some(diff) = diff else {
return Verdict::NotObservable;
};
let state = diff
.property_change_for(trigger, "expanded")
.map(|c| c.after == want.to_string());
let content = if want {
diff.has_additions()
} else {
diff.has_removals()
};
match (state, content) {
(Some(true), true) => Verdict::StateAndContent,
(Some(true), false) => Verdict::StateOnly,
(Some(false), _) => Verdict::WrongDirection,
(None, true) => Verdict::ContentOnly,
(None, false) => Verdict::NoChange,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Start {
State(bool),
NoState,
Gone,
}
impl Start {
fn as_str(self) -> &'static str {
match self {
Start::State(true) => "expanded",
Start::State(false) => "collapsed",
Start::NoState => "no_expanded_state",
Start::Gone => "trigger_not_in_tree",
}
}
}
fn start_state(snapshot: &AXSnapshot, trigger: i64) -> Start {
match snapshot.tree.node_by_backend_id(trigger) {
Some(node) => match node.get_property_bool("expanded") {
Some(v) => Start::State(v),
None => Start::NoState,
},
None => Start::Gone,
}
}
async fn snapshot(page: &Page, label: &str, started: Instant) -> Option<AXSnapshot> {
match AXSnapshot::capture(page, label, started.elapsed().as_millis() as u64).await {
Ok(snapshot) => Some(snapshot),
Err(e) => {
tracing::warn!("disclosure: Aufnahme '{label}' fehlgeschlagen: {e}");
None
}
}
}
fn missed_state_kind(want_open: bool) -> (InteractiveFindingKind, Severity) {
if want_open {
(InteractiveFindingKind::DisclosureNotOpened, Severity::High)
} else {
(
InteractiveFindingKind::DisclosureNotClosed,
Severity::Medium,
)
}
}
struct Findings {
journey: String,
items: Vec<InteractiveFinding>,
}
impl Findings {
fn push(
&mut self,
kind: InteractiveFindingKind,
severity: Severity,
before: Option<String>,
after: String,
) {
if self.items.iter().any(|f| f.kind == kind) {
return;
}
self.items.push(InteractiveFinding::new(
"StateTransition",
kind,
None,
severity,
self.journey.clone(),
before,
Some(after),
InteractiveFindingValues::default(),
));
}
}
pub async fn test(
page: &Page,
candidate: &JourneyCandidate,
index: usize,
) -> Result<(JourneyTrace, Vec<InteractiveFinding>)> {
let started = Instant::now();
let journey_name = format!("disclosure_{index}");
let mut trace = JourneyTrace {
journey: journey_name.clone(),
steps: Vec::new(),
};
let mut findings = Findings {
journey: journey_name,
items: Vec::new(),
};
let trigger_id = match candidate.trigger_backend_id {
Some(id) => id,
None => return Ok((trace, findings.items)),
};
let before_snapshot = snapshot(page, "initial", started).await;
let start = before_snapshot
.as_ref()
.map(|s| start_state(s, trigger_id))
.unwrap_or(Start::NoState);
let want_open = start != Start::State(true);
trace.steps.push(JourneyStep {
action: "initial_state".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: Some(start.as_str().to_string()),
snapshot_label: Some("initial".to_string()),
});
if start == Start::Gone {
return Ok((trace, findings.items));
}
if let Err(e) = pointer::synthetic_click_backend(page, trigger_id).await {
tracing::warn!("disclosure: click on backend node {trigger_id} failed: {e}");
return Ok((trace, findings.items));
}
trace.steps.push(JourneyStep {
action: "synthetic_click".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: None,
snapshot_label: Some("after_first_click".to_string()),
});
let _ = stability::settle_after_action(page).await;
let after_first = snapshot(page, "after_first_click", started).await;
let first_diff = match (&before_snapshot, &after_first) {
(Some(before), Some(after)) => Some(AXTreeDiff::between(before, after)),
_ => None,
};
let first = diff_verdict(first_diff.as_ref(), trigger_id, want_open);
trace.steps.push(JourneyStep {
action: if want_open {
"check_expanded".to_string()
} else {
"check_collapsed".to_string()
},
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: Some(first.as_str().to_string()),
snapshot_label: Some("after_first_click".to_string()),
});
let (finding, proceed) = outcome(first, want_open);
if let Some((kind, severity)) = finding {
findings.push(
kind,
severity,
Some("initial".to_string()),
"after_first_click".to_string(),
);
}
if !proceed {
return Ok((trace, findings.items));
}
if let Err(e) = pointer::synthetic_click_backend(page, trigger_id).await {
tracing::warn!("disclosure: second click on backend node {trigger_id} failed: {e}");
return Ok((trace, findings.items));
}
trace.steps.push(JourneyStep {
action: "synthetic_click".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: None,
snapshot_label: Some("after_second_click".to_string()),
});
let _ = stability::settle_after_action(page).await;
let after_second = snapshot(page, "after_second_click", started).await;
let second_diff = match (&after_first, &after_second) {
(Some(before), Some(after)) => Some(AXTreeDiff::between(before, after)),
_ => None,
};
let second = diff_verdict(second_diff.as_ref(), trigger_id, !want_open);
trace.steps.push(JourneyStep {
action: if want_open {
"check_collapsed".to_string()
} else {
"check_expanded".to_string()
},
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: Some(second.as_str().to_string()),
snapshot_label: Some("after_second_click".to_string()),
});
if let Some((kind, severity)) = outcome(second, !want_open).0 {
findings.push(
kind,
severity,
Some("after_first_click".to_string()),
"after_second_click".to_string(),
);
}
Ok((trace, findings.items))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue, FocusSnapshot};
fn node(ax_id: &str, backend: i64, expanded: Option<bool>) -> AXNode {
AXNode {
node_id: ax_id.to_string(),
ignored: false,
ignored_reasons: Vec::new(),
role: Some("button".to_string()),
name: None,
name_source: None,
description: None,
value: None,
properties: expanded
.map(|v| {
vec![AXProperty {
name: "expanded".to_string(),
value: AXValue::Bool(v),
}]
})
.unwrap_or_default(),
child_ids: Vec::new(),
parent_id: None,
backend_dom_node_id: Some(backend),
}
}
fn snap(nodes: Vec<AXNode>) -> AXSnapshot {
AXSnapshot::new(
"s",
"https://x",
"T",
0,
AXTree::from_nodes(nodes),
FocusSnapshot::default(),
)
}
fn verdict(before: Vec<AXNode>, after: Vec<AXNode>, want: bool) -> Verdict {
let diff = AXTreeDiff::between(&snap(before), &snap(after));
diff_verdict(Some(&diff), 42, want)
}
#[test]
fn aufklappen_mit_neuem_inhalt_ist_der_normalfall() {
assert_eq!(
verdict(
vec![node("a", 42, Some(false))],
vec![node("a", 42, Some(true)), node("b", 43, None)],
true
),
Verdict::StateAndContent
);
}
#[test]
fn zustand_ohne_inhalt_wird_unterschieden() {
assert_eq!(
verdict(
vec![node("a", 42, Some(false))],
vec![node("a", 42, Some(true))],
true
),
Verdict::StateOnly
);
}
#[test]
fn inhalt_ohne_zustandsmeldung_wird_unterschieden() {
assert_eq!(
verdict(
vec![node("a", 42, None)],
vec![node("a", 42, None), node("b", 43, None)],
true
),
Verdict::ContentOnly
);
}
#[test]
fn keine_wahrnehmbare_wirkung() {
assert_eq!(
verdict(
vec![node("a", 42, Some(false))],
vec![node("a", 42, Some(false))],
true
),
Verdict::NoChange
);
}
#[test]
fn fremder_knoten_zaehlt_nicht_als_erfolg() {
assert_eq!(
verdict(
vec![node("a", 42, Some(false)), node("b", 99, Some(false))],
vec![node("a", 42, Some(false)), node("b", 99, Some(true))],
true
),
Verdict::NoChange
);
}
#[test]
fn fehlende_aufnahme_ist_nicht_beobachtbar() {
assert_eq!(diff_verdict(None, 42, true), Verdict::NotObservable);
assert_eq!(outcome(Verdict::NotObservable, true), (None, false));
}
#[test]
fn richtung_folgt_dem_ausgangszustand() {
assert_eq!(
start_state(&snap(vec![node("a", 42, Some(true))]), 42),
Start::State(true)
);
assert_eq!(
start_state(&snap(vec![node("a", 42, Some(false))]), 42),
Start::State(false)
);
}
#[test]
fn fehlender_zustand_und_fehlender_knoten_sind_verschieden() {
assert_eq!(
start_state(&snap(vec![node("a", 42, None)]), 42),
Start::NoState
);
assert_eq!(
start_state(&snap(vec![node("a", 42, Some(true))]), 7),
Start::Gone
);
}
#[test]
fn falsche_richtung_ohne_ausgangswert_ist_keine_aussage() {
assert_eq!(
verdict(
vec![node("a", 42, None)],
vec![node("a", 42, Some(false))],
true
),
Verdict::WrongDirection
);
assert_eq!(outcome(Verdict::WrongDirection, true), (None, false));
}
#[test]
fn zuklappen_misst_verschwundenen_inhalt() {
assert_eq!(
verdict(
vec![node("a", 42, Some(true)), node("b", 43, None)],
vec![node("a", 42, Some(false))],
false
),
Verdict::StateAndContent
);
}
#[test]
fn inhalt_ohne_zustand_erzeugt_genau_einen_befund() {
let (finding, proceed) = outcome(Verdict::ContentOnly, true);
assert_eq!(
finding.map(|f| f.0),
Some(InteractiveFindingKind::DisclosureContentWithoutState)
);
assert!(
!proceed,
"ohne bekannten Zustand ist der zweite Klick blind"
);
}
#[test]
fn zustand_ohne_inhalt_laeuft_weiter() {
let (finding, proceed) = outcome(Verdict::StateOnly, true);
assert_eq!(
finding.map(|f| f.0),
Some(InteractiveFindingKind::DisclosureStateWithoutContent)
);
assert!(proceed);
}
#[test]
fn wirkungslose_betaetigung_meldet_die_richtung() {
assert_eq!(
outcome(Verdict::NoChange, true).0.map(|f| f.0),
Some(InteractiveFindingKind::DisclosureNotOpened)
);
assert_eq!(
outcome(Verdict::NoChange, false).0.map(|f| f.0),
Some(InteractiveFindingKind::DisclosureNotClosed)
);
}
#[test]
fn befundart_richtet_sich_nach_der_richtung() {
assert_eq!(
missed_state_kind(true).0,
InteractiveFindingKind::DisclosureNotOpened
);
assert_eq!(
missed_state_kind(false).0,
InteractiveFindingKind::DisclosureNotClosed
);
}
#[test]
fn gleiche_befundart_wird_nicht_doppelt_gemeldet() {
let mut f = Findings {
journey: "disclosure_0".to_string(),
items: Vec::new(),
};
f.push(
InteractiveFindingKind::DisclosureContentWithoutState,
Severity::Medium,
None,
"a".to_string(),
);
f.push(
InteractiveFindingKind::DisclosureContentWithoutState,
Severity::Medium,
None,
"b".to_string(),
);
assert_eq!(f.items.len(), 1);
assert_eq!(f.items[0].after_snapshot_label.as_deref(), Some("a"));
}
}