use std::time::Instant;
use chromiumoxide::Page;
use crate::accessibility::{AXSnapshot, AXTree, AXTreeDiff, AXValue};
use crate::audit::normalized::{
FindingUncertainty, InteractiveFinding, InteractiveFindingKind, InteractiveFindingValues,
JourneyStep, JourneyTrace,
};
use crate::error::Result;
use crate::interaction::stability::StabilityProvenance;
use crate::interaction::{pointer, stability};
use crate::patterns::{JourneyCandidate, NATIVE_DISCLOSURE_ROLES};
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 controlled_region(snapshot: &AXSnapshot, trigger: i64) -> Option<i64> {
let node = snapshot.tree.node_by_backend_id(trigger)?;
let controls = node
.properties
.iter()
.find_map(|p| match (&p.name[..], &p.value) {
("controls", AXValue::Node { related_nodes }) => {
related_nodes.iter().find_map(|r| r.backend_dom_node_id)
}
_ => None,
});
if controls.is_some() {
return controls;
}
if node
.role
.as_deref()
.is_some_and(|role| NATIVE_DISCLOSURE_ROLES.contains(&role))
{
let parent = node.parent_id.as_deref()?;
return snapshot.tree.get_node(parent)?.backend_dom_node_id;
}
None
}
fn within_region(tree: &AXTree, node_id: &str, region: i64) -> bool {
let mut current = tree.get_node(node_id);
for _ in 0..1024 {
let Some(node) = current else {
return false;
};
if node.backend_dom_node_id == Some(region) {
return true;
}
current = node.parent_id.as_deref().and_then(|id| tree.get_node(id));
}
false
}
fn content_changed(
diff: &AXTreeDiff,
before: &AXSnapshot,
after: &AXSnapshot,
region: Option<i64>,
want: bool,
) -> bool {
match (region, want) {
(None, true) => diff.has_additions(),
(None, false) => diff.has_removals(),
(Some(r), true) => diff
.added
.iter()
.any(|id| within_region(&after.tree, id, r)),
(Some(r), false) => diff
.removed
.iter()
.any(|id| within_region(&before.tree, id, r)),
}
}
fn verdict_between(
before: Option<&AXSnapshot>,
after: Option<&AXSnapshot>,
trigger: i64,
region: Option<i64>,
want: bool,
) -> Verdict {
let (Some(before), Some(after)) = (before, after) else {
return Verdict::NotObservable;
};
let diff = AXTreeDiff::between(before, after);
let state = diff
.property_change_for(trigger, "expanded")
.map(|c| c.after == want.to_string());
let content = content_changed(&diff, before, after, region, want);
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 crate::accessibility::capture_snapshot(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,
uncertainty: Option<FindingUncertainty>,
) {
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(),
)
.with_uncertainty(uncertainty),
);
}
}
fn late_reaction(settle: &StabilityProvenance) -> Option<FindingUncertainty> {
(settle.waited_ms < stability::JOURNEY_SETTLE_BUDGET_MS).then_some(
FindingUncertainty::LateReactionPossible {
waited_ms: settle.waited_ms,
},
)
}
fn settle_label(settle: &StabilityProvenance) -> String {
let status = serde_json::to_value(settle.status)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
format!(
"settle:{status} after {} ms, {} mutations",
settle.waited_ms, settle.mutation_count
)
}
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()),
});
let region = before_snapshot
.as_ref()
.and_then(|s| controlled_region(s, trigger_id));
trace.steps.push(JourneyStep {
action: "controlled_region".to_string(),
target: region.map(|r| format!("backend_node:{r}")),
focus: None,
result: Some(
if region.is_some() {
"scoped"
} else {
"page_wide"
}
.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));
}
let first_settle = stability::settle_after_action(page).await;
trace.steps.push(JourneyStep {
action: "synthetic_click".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: Some(settle_label(&first_settle)),
snapshot_label: Some("after_first_click".to_string()),
});
let after_first = snapshot(page, "after_first_click", started).await;
let first = verdict_between(
before_snapshot.as_ref(),
after_first.as_ref(),
trigger_id,
region,
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(),
late_reaction(&first_settle),
);
}
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));
}
let second_settle = stability::settle_after_action(page).await;
trace.steps.push(JourneyStep {
action: "synthetic_click".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: Some(settle_label(&second_settle)),
snapshot_label: Some("after_second_click".to_string()),
});
let after_second = snapshot(page, "after_second_click", started).await;
let second = verdict_between(
after_first.as_ref(),
after_second.as_ref(),
trigger_id,
region,
!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(),
late_reaction(&second_settle),
);
}
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 {
verdict_between(Some(&snap(before)), Some(&snap(after)), 42, None, 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!(
verdict_between(None, None, 42, None, 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(),
None,
);
f.push(
InteractiveFindingKind::DisclosureContentWithoutState,
Severity::Medium,
None,
"b".to_string(),
None,
);
assert_eq!(f.items.len(), 1);
assert_eq!(f.items[0].after_snapshot_label.as_deref(), Some("a"));
}
fn settled(status: stability::StabilityStatus, waited_ms: u64) -> StabilityProvenance {
StabilityProvenance {
viewport: "journey".to_string(),
status,
waited_ms,
mutation_count: 0,
reason: None,
}
}
#[test]
fn nur_ein_vorzeitig_beendetes_warten_macht_den_befund_unsicher() {
use stability::{StabilityStatus, JOURNEY_SETTLE_BUDGET_MS};
assert_eq!(
late_reaction(&settled(StabilityStatus::Stable, 210)),
Some(FindingUncertainty::LateReactionPossible { waited_ms: 210 })
);
assert_eq!(
late_reaction(&settled(
StabilityStatus::BudgetExhausted,
JOURNEY_SETTLE_BUDGET_MS
)),
None
);
}
fn child(ax_id: &str, backend: i64, parent: &str) -> AXNode {
AXNode {
parent_id: Some(parent.to_string()),
role: Some("StaticText".to_string()),
..node(ax_id, backend, None)
}
}
fn controls(mut trigger: AXNode, region: i64) -> AXNode {
trigger.properties.push(AXProperty {
name: "controls".to_string(),
value: AXValue::Node {
related_nodes: vec![crate::accessibility::RelatedNode {
backend_dom_node_id: Some(region),
idref: None,
text: None,
}],
},
});
trigger
}
#[test]
fn inhalt_ausserhalb_des_gesteuerten_bereichs_zaehlt_nicht() {
let region = node("panel", 50, None);
let before = vec![controls(node("a", 42, Some(false)), 50), region.clone()];
let after_elsewhere = vec![
controls(node("a", 42, Some(true)), 50),
region.clone(),
node("footer-img", 77, None),
];
let after_inside = vec![
controls(node("a", 42, Some(true)), 50),
region,
child("text", 51, "panel"),
];
let b = snap(before);
assert_eq!(controlled_region(&b, 42), Some(50));
assert_eq!(
verdict_between(Some(&b), Some(&snap(after_elsewhere)), 42, Some(50), true),
Verdict::StateOnly
);
assert_eq!(
verdict_between(Some(&b), Some(&snap(after_inside)), 42, Some(50), true),
Verdict::StateAndContent
);
}
#[test]
fn zuklappen_prueft_verschwundenes_im_frueheren_baum() {
let region = node("panel", 50, None);
let before = vec![
controls(node("a", 42, Some(true)), 50),
region.clone(),
child("text", 51, "panel"),
];
let after = vec![controls(node("a", 42, Some(false)), 50), region];
assert_eq!(
verdict_between(Some(&snap(before)), Some(&snap(after)), 42, Some(50), false),
Verdict::StateAndContent
);
}
#[test]
fn summary_steuert_das_umgebende_details() {
for role in ["DisclosureTriangle", "DisclosureTriangleGrouped"] {
let details = node("details", 60, None);
let summary = AXNode {
parent_id: Some("details".to_string()),
role: Some(role.to_string()),
..node("summary", 42, Some(false))
};
assert_eq!(
controlled_region(&snap(vec![details, summary]), 42),
Some(60),
"{role}"
);
}
}
#[test]
fn ohne_beziehung_bleibt_die_aussage_seitenweit() {
assert_eq!(
controlled_region(&snap(vec![node("a", 42, Some(false))]), 42),
None
);
}
}