use axterminator::intent::{NodeId, SceneGraph, SceneNode, build_scene_from_nodes, extract_intent};
use axterminator::intent_matching::{fuzzy_score, infer_role_hint, tokenise};
fn button(id: usize, title: &str) -> SceneNode {
SceneNode {
id: NodeId(id),
parent: None,
children: vec![],
role: Some("AXButton".into()),
title: Some(title.into()),
label: None,
value: None,
description: None,
identifier: None,
bounds: Some((10.0, f64::from(id as u32) * 40.0 + 10.0, 80.0, 30.0)),
enabled: true,
depth: 1,
}
}
fn text_field(id: usize, label: &str) -> SceneNode {
SceneNode {
id: NodeId(id),
parent: None,
children: vec![],
role: Some("AXTextField".into()),
title: None,
label: Some(label.into()),
value: None,
description: None,
identifier: None,
bounds: Some((10.0, f64::from(id as u32) * 40.0 + 10.0, 200.0, 25.0)),
enabled: true,
depth: 1,
}
}
fn static_text(id: usize, value: &str) -> SceneNode {
SceneNode {
id: NodeId(id),
parent: None,
children: vec![],
role: Some("AXStaticText".into()),
title: None,
label: None,
value: Some(value.into()),
description: None,
identifier: None,
bounds: Some((10.0, f64::from(id as u32) * 20.0, 300.0, 16.0)),
enabled: true,
depth: 2,
}
}
fn window(id: usize, title: &str) -> SceneNode {
SceneNode {
id: NodeId(id),
parent: None,
children: vec![],
role: Some("AXWindow".into()),
title: Some(title.into()),
label: None,
value: None,
description: None,
identifier: None,
bounds: Some((0.0, 0.0, 800.0, 600.0)),
enabled: true,
depth: 0,
}
}
#[test]
fn scene_graph_empty_has_zero_nodes() {
let graph = SceneGraph::empty();
assert_eq!(graph.len(), 0);
assert!(graph.is_empty());
}
#[test]
fn scene_graph_len_matches_node_count() {
let nodes: Vec<SceneNode> = (0..5).map(|i| button(i, "OK")).collect();
let graph = build_scene_from_nodes(nodes);
assert_eq!(graph.len(), 5);
}
#[test]
fn scene_graph_get_by_id_retrieves_correct_node() {
let graph = build_scene_from_nodes(vec![button(0, "Save"), button(1, "Cancel")]);
assert_eq!(graph.get(NodeId(0)).unwrap().title.as_deref(), Some("Save"));
assert_eq!(
graph.get(NodeId(1)).unwrap().title.as_deref(),
Some("Cancel")
);
}
#[test]
fn scene_graph_get_out_of_bounds_returns_none() {
let graph = build_scene_from_nodes(vec![button(0, "OK")]);
assert!(graph.get(NodeId(1)).is_none());
}
#[test]
fn scene_graph_nodes_by_role_returns_only_matching() {
let nodes = vec![button(0, "OK"), text_field(1, "Email"), button(2, "Cancel")];
let graph = build_scene_from_nodes(nodes);
let buttons = graph.nodes_by_role("AXButton");
assert_eq!(buttons.len(), 2);
assert!(
buttons
.iter()
.all(|n| n.role.as_deref() == Some("AXButton"))
);
}
#[test]
fn scene_graph_root_is_first_inserted_node() {
let graph = build_scene_from_nodes(vec![window(0, "Main"), button(1, "OK")]);
let root = graph.root().unwrap();
assert_eq!(root.role.as_deref(), Some("AXWindow"));
}
#[test]
fn scene_node_text_labels_collects_all_non_empty_fields() {
let node = SceneNode {
id: NodeId(0),
parent: None,
children: vec![],
role: Some("AXButton".into()),
title: Some("OK".into()),
label: None,
value: None,
description: Some("Confirm action".into()),
identifier: Some("btn_ok".into()),
bounds: None,
enabled: true,
depth: 0,
};
let labels = node.text_labels();
assert!(labels.contains(&"OK"));
assert!(labels.contains(&"Confirm action"));
assert!(labels.contains(&"btn_ok"));
assert_eq!(labels.len(), 3);
}
#[test]
fn scene_node_center_is_midpoint_of_bounds() {
let node = SceneNode {
id: NodeId(0),
parent: None,
children: vec![],
role: None,
title: None,
label: None,
value: None,
description: None,
identifier: None,
bounds: Some((20.0, 40.0, 60.0, 20.0)),
enabled: true,
depth: 0,
};
let (cx, cy) = node.center().unwrap();
assert_eq!(cx, 50.0);
assert_eq!(cy, 50.0);
}
#[test]
fn fuzzy_score_identical_strings_score_one() {
assert_eq!(fuzzy_score("login", "login"), 1.0);
}
#[test]
fn fuzzy_score_case_insensitive_identical_is_one() {
assert_eq!(fuzzy_score("LOGIN", "login"), 1.0);
}
#[test]
fn fuzzy_score_prefix_is_high() {
let s = fuzzy_score("submit form", "submit");
assert!(s > 0.5, "prefix should score > 0.5, got {s}");
}
#[test]
fn fuzzy_score_unrelated_strings_is_low() {
let s = fuzzy_score("qqqq", "zzzz");
assert!(s < 0.2, "unrelated strings should score < 0.2, got {s}");
}
#[test]
fn fuzzy_score_empty_inputs_are_zero() {
assert_eq!(fuzzy_score("", "ok"), 0.0);
assert_eq!(fuzzy_score("ok", ""), 0.0);
assert_eq!(fuzzy_score("", ""), 0.0);
}
#[test]
fn tokenise_removes_english_stop_words() {
let tokens = tokenise("click the submit button");
assert!(!tokens.contains(&"the".to_string()));
assert!(tokens.contains(&"click".to_string()));
}
#[test]
fn tokenise_lowercases_all_tokens() {
let tokens = tokenise("SAVE FILE");
assert!(tokens.iter().all(|t| t == t.to_lowercase().as_str()));
}
#[test]
fn tokenise_empty_string_is_empty_vec() {
assert!(tokenise("").is_empty());
}
#[test]
fn infer_role_hint_click_maps_to_button() {
let hint = infer_role_hint(&["click".to_string()]);
assert_eq!(hint, Some("AXButton"));
}
#[test]
fn infer_role_hint_type_maps_to_text_field() {
let hint = infer_role_hint(&["type".to_string()]);
assert_eq!(hint, Some("AXTextField"));
}
#[test]
fn infer_role_hint_unknown_token_returns_none() {
let hint = infer_role_hint(&["foobar".to_string()]);
assert!(hint.is_none());
}
#[test]
fn extract_intent_finds_exact_title_match() {
let graph = build_scene_from_nodes(vec![button(0, "Submit"), button(1, "Cancel")]);
let results = extract_intent(&graph, "submit");
assert!(!results.is_empty());
assert_eq!(results[0].node_id, NodeId(0));
}
#[test]
fn extract_intent_results_sorted_descending_by_confidence() {
let graph = build_scene_from_nodes(vec![
button(0, "Submit"),
button(1, "Cancel"),
button(2, "Submit Form"),
]);
let results = extract_intent(&graph, "submit");
for window in results.windows(2) {
assert!(
window[0].confidence >= window[1].confidence,
"results not sorted: {:.3} < {:.3}",
window[0].confidence,
window[1].confidence
);
}
}
#[test]
fn extract_intent_empty_scene_returns_empty() {
let graph = SceneGraph::empty();
let results = extract_intent(&graph, "click submit");
assert!(results.is_empty());
}
#[test]
fn extract_intent_all_confidences_in_unit_interval() {
let graph = build_scene_from_nodes(vec![
button(0, "OK"),
text_field(1, "Username"),
static_text(2, "Welcome"),
]);
let results = extract_intent(&graph, "ok");
for m in &results {
assert!(
(0.0..=1.0).contains(&m.confidence),
"confidence {:.3} out of range",
m.confidence
);
}
}
#[test]
fn extract_intent_match_reason_is_non_empty() {
let graph = build_scene_from_nodes(vec![button(0, "Save")]);
let results = extract_intent(&graph, "save");
assert!(!results.is_empty());
assert!(
!results[0].match_reason.is_empty(),
"match_reason must not be blank"
);
}
#[test]
fn extract_intent_role_hint_prefers_button_for_click_query() {
let graph = build_scene_from_nodes(vec![button(0, "Login"), text_field(1, "Login")]);
let results = extract_intent(&graph, "click the login button");
assert!(!results.is_empty());
assert_eq!(
results[0].node_id,
NodeId(0),
"Button should beat text field when query says 'button'"
);
}
#[test]
fn extract_intent_role_hint_prefers_text_field_for_type_query() {
let graph = build_scene_from_nodes(vec![button(0, "Password"), text_field(1, "Password")]);
let results = extract_intent(&graph, "type password");
assert!(!results.is_empty());
assert_eq!(
results[0].node_id,
NodeId(1),
"TextField should beat button when query says 'type'"
);
}
#[test]
fn extract_intent_case_insensitive_matching() {
let graph = build_scene_from_nodes(vec![button(0, "Save File")]);
let results = extract_intent(&graph, "save file");
assert!(!results.is_empty());
assert_eq!(results[0].node_id, NodeId(0));
}
#[test]
fn extract_intent_fuzzy_partial_match_returns_result() {
let graph = build_scene_from_nodes(vec![button(0, "Submit")]);
let results = extract_intent(&graph, "subm");
assert!(!results.is_empty(), "fuzzy prefix should find 'Submit'");
}
#[test]
fn extract_intent_disabled_element_ranks_lower_than_enabled() {
let mut disabled = button(1, "OK");
disabled.enabled = false;
let graph = build_scene_from_nodes(vec![button(0, "OK"), disabled]);
let results = extract_intent(&graph, "ok");
assert!(results.len() >= 2);
assert_eq!(results[0].node_id, NodeId(0));
}
#[test]
fn extract_intent_returns_node_id_stable_reference() {
let graph = build_scene_from_nodes(vec![button(0, "A"), button(1, "B"), button(2, "C")]);
let results = extract_intent(&graph, "b");
if let Some(m) = results.first() {
assert!(graph.get(m.node_id).is_some(), "NodeId must be valid");
}
}