use std::collections::HashMap;
use crate::accessibility::{AXNode, AXTree};
use super::{
JourneyCandidate, JourneyKind, PatternAnalysis, PatternConfidence, PatternKind,
PURCHASE_FINAL_HINTS,
};
const MAX_FORM_CANDIDATES: usize = 3;
const SUBMIT_HINTS: &[&str] = &[
"submit",
"send",
"senden",
"absenden",
"anmelden",
"registrieren",
"register",
"login",
"sign in",
"log in",
"suchen",
"search",
"apply",
"bewerben",
"kontakt",
"contact",
];
pub(super) fn detect(tree: &AXTree, out: &mut PatternAnalysis) {
let required_controls: Vec<_> = tree
.iter()
.filter(|n| {
matches!(
n.role.as_deref(),
Some("textbox")
| Some("searchbox")
| Some("combobox")
| Some("listbox")
| Some("radio")
| Some("checkbox")
| Some("spinbutton")
) && n.get_property_bool("required").unwrap_or(false)
})
.collect();
if required_controls.is_empty() {
return;
}
let mut buttons: Vec<&AXNode> = tree
.iter()
.filter(|n| matches!(n.role.as_deref(), Some("button")))
.filter(|n| {
let name = n.name.as_deref().unwrap_or("").to_lowercase();
!PURCHASE_FINAL_HINTS.iter().any(|h| name.contains(h))
})
.filter(|n| {
let name = n.name.as_deref().unwrap_or("").to_lowercase();
SUBMIT_HINTS.iter().any(|h| name.contains(h))
})
.collect();
if buttons.is_empty() {
return;
}
buttons.sort_by(|a, b| a.node_id.cmp(&b.node_id));
let button_chains: Vec<(&AXNode, Vec<&str>)> = buttons
.iter()
.map(|b| (*b, ancestor_chain_from_root(tree, b)))
.collect();
let mut clusters: HashMap<&str, (&AXNode, usize)> = HashMap::new();
for control in &required_controls {
let control_chain = ancestor_chain_from_root(tree, control);
let nearest = button_chains
.iter()
.max_by_key(|(_, chain)| common_prefix_len(&control_chain, chain));
if let Some((button, _)) = nearest {
clusters
.entry(button.node_id.as_str())
.or_insert((*button, 0))
.1 += 1;
}
}
if clusters.is_empty() {
return;
}
let mut ranked: Vec<(&AXNode, usize)> = clusters.into_values().collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.node_id.cmp(&b.0.node_id)));
ranked.truncate(MAX_FORM_CANDIDATES);
for (trigger, field_count) in ranked {
out.add_recognized(
"Form",
format!(
"{field_count} required {} detected; submit trigger: {}",
if field_count == 1 { "field" } else { "fields" },
trigger.name.as_deref().unwrap_or("(unnamed button)")
),
PatternConfidence::Partial,
);
out.journey_candidates.push(JourneyCandidate {
pattern_kind: PatternKind::Form,
trigger_backend_id: trigger.backend_dom_node_id,
controlled_backend_id: None,
confidence: 0.75,
required_journey: JourneyKind::FormErrorSubmit,
});
}
}
fn ancestor_chain_from_root<'a>(tree: &'a AXTree, node: &'a AXNode) -> Vec<&'a str> {
let mut chain: Vec<&'a str> = Vec::new();
let mut current = node.parent_id.as_deref();
while let Some(id) = current {
chain.push(id);
current = tree.nodes.get(id).and_then(|p| p.parent_id.as_deref());
}
chain.reverse();
chain
}
fn common_prefix_len(a: &[&str], b: &[&str]) -> usize {
a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue};
fn required_textbox(id: &str) -> AXNode {
required_textbox_in(id, None)
}
fn required_textbox_in(id: &str, parent_id: Option<&str>) -> AXNode {
AXNode {
node_id: id.into(),
ignored: false,
ignored_reasons: vec![],
role: Some("textbox".into()),
name: Some("Email".into()),
name_source: None,
description: None,
value: None,
properties: vec![AXProperty {
name: "required".into(),
value: AXValue::Bool(true),
}],
child_ids: vec![],
parent_id: parent_id.map(String::from),
backend_dom_node_id: None,
}
}
fn button(id: &str, name: &str) -> AXNode {
button_full(id, name, None, 42)
}
fn button_full(id: &str, name: &str, parent_id: Option<&str>, backend_id: i64) -> AXNode {
AXNode {
node_id: id.into(),
ignored: false,
ignored_reasons: vec![],
role: Some("button".into()),
name: Some(name.into()),
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: parent_id.map(String::from),
backend_dom_node_id: Some(backend_id),
}
}
fn container(id: &str) -> AXNode {
AXNode {
node_id: id.into(),
ignored: false,
ignored_reasons: vec![],
role: Some("generic".into()),
name: None,
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
}
}
#[test]
fn picks_hinted_submit_button() {
let tree = AXTree::from_nodes(vec![required_textbox("1"), button("2", "Absenden")]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert_eq!(a.journey_candidates.len(), 1);
assert_eq!(
a.journey_candidates[0].required_journey,
JourneyKind::FormErrorSubmit
);
}
#[test]
fn no_candidate_without_hint_match_no_first_button_fallback() {
let tree = AXTree::from_nodes(vec![required_textbox("1"), button("2", "Menu")]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert!(a.journey_candidates.is_empty());
}
#[test]
fn purchase_final_button_never_selected_even_as_only_button() {
for name in [
"Jetzt kaufen",
"Zahlungspflichtig bestellen",
"Buy now",
"Place order",
"Book now",
] {
let tree = AXTree::from_nodes(vec![required_textbox("1"), button("2", name)]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert!(
a.journey_candidates.is_empty(),
"purchase-final button {name:?} must never be selected as a trigger"
);
}
}
#[test]
fn purchase_final_button_excluded_even_when_a_safe_button_also_present() {
let tree = AXTree::from_nodes(vec![
required_textbox("1"),
button("2", "Jetzt kaufen"),
button("3", "Suchen"),
]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert_eq!(a.journey_candidates.len(), 1);
}
#[test]
fn two_separate_forms_yield_two_distinct_candidates() {
let tree = AXTree::from_nodes(vec![
container("form-a"),
required_textbox_in("f1", Some("form-a")),
button_full("b1", "Absenden", Some("form-a"), 1),
container("form-b"),
required_textbox_in("f2", Some("form-b")),
button_full("b2", "Suchen", Some("form-b"), 2),
]);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert_eq!(a.journey_candidates.len(), 2);
let backend_ids: std::collections::BTreeSet<_> = a
.journey_candidates
.iter()
.map(|c| c.trigger_backend_id)
.collect();
assert_eq!(
backend_ids,
std::collections::BTreeSet::from([Some(1), Some(2)]),
"each form must resolve to its own nearby button, not a shared/wrong one"
);
}
#[test]
fn candidates_capped_at_three_even_with_more_forms() {
let mut nodes = Vec::new();
for i in 1..=4 {
let form_id = format!("form-{i}");
let field_id = format!("f{i}");
let button_id = format!("b{i}");
nodes.push(container(&form_id));
nodes.push(required_textbox_in(&field_id, Some(&form_id)));
nodes.push(button_full(&button_id, "Absenden", Some(&form_id), i));
}
let tree = AXTree::from_nodes(nodes);
let mut a = PatternAnalysis::default();
detect(&tree, &mut a);
assert_eq!(
a.journey_candidates.len(),
MAX_FORM_CANDIDATES,
"must cap at MAX_FORM_CANDIDATES even when more distinct forms exist"
);
}
}