use std::collections::VecDeque;
use super::types::{StuckAlert, UIElement};
const SCREEN_HISTORY_SIZE: usize = 8;
const UNCHANGED_THRESHOLD: usize = 3;
const ACTION_HISTORY_SIZE: usize = 8;
const REPEAT_THRESHOLD: usize = 3;
const NAV_ACTIONS: &[&str] = &["back", "home", "recent"];
const DRIFT_THRESHOLD: usize = 4;
const DRIFT_WINDOW: usize = 5;
#[derive(Debug)]
pub struct StuckDetector {
screen_hashes: VecDeque<String>,
action_history: VecDeque<String>,
}
impl Default for StuckDetector {
fn default() -> Self {
Self {
screen_hashes: VecDeque::with_capacity(SCREEN_HISTORY_SIZE),
action_history: VecDeque::with_capacity(ACTION_HISTORY_SIZE),
}
}
}
impl StuckDetector {
pub fn hash_screen(elements: &[UIElement]) -> String {
let mut parts: Vec<String> = elements.iter().map(|e| e.hash_key()).collect();
parts.sort();
parts.join(";")
}
pub fn observe_screen(&mut self, elements: &[UIElement]) -> Vec<StuckAlert> {
let hash = Self::hash_screen(elements);
let mut alerts = Vec::new();
let consecutive_same = self
.screen_hashes
.iter()
.rev()
.take_while(|h| **h == hash)
.count();
if consecutive_same >= UNCHANGED_THRESHOLD - 1 {
alerts.push(StuckAlert::ScreenUnchanged(format!(
"Screen unchanged for {} consecutive observations. \
Try a different action or scroll to reveal new elements.",
consecutive_same + 1
)));
}
if self.screen_hashes.len() >= SCREEN_HISTORY_SIZE {
self.screen_hashes.pop_front();
}
self.screen_hashes.push_back(hash);
alerts
}
pub fn observe_action(&mut self, action: &str) -> Vec<StuckAlert> {
let mut alerts = Vec::new();
let sig = action.to_lowercase();
let repeat_count = self
.action_history
.iter()
.rev()
.take(ACTION_HISTORY_SIZE)
.filter(|a| **a == sig)
.count();
if repeat_count >= REPEAT_THRESHOLD - 1 {
alerts.push(StuckAlert::ActionRepeated(format!(
"Action '{}' repeated {} times in the last {} actions. \
Consider a different approach.",
action,
repeat_count + 1,
ACTION_HISTORY_SIZE
)));
}
let recent: Vec<&String> = self
.action_history
.iter()
.rev()
.take(DRIFT_WINDOW - 1)
.collect();
let nav_count = recent
.iter()
.filter(|a| NAV_ACTIONS.contains(&a.as_str()))
.count()
+ if NAV_ACTIONS.contains(&sig.as_str()) {
1
} else {
0
};
if nav_count >= DRIFT_THRESHOLD {
alerts.push(StuckAlert::NavigationDrift(format!(
"{} navigation actions (back/home/recent) in the last {} actions. \
The agent may be navigating without clear purpose.",
nav_count, DRIFT_WINDOW
)));
}
if self.action_history.len() >= ACTION_HISTORY_SIZE {
self.action_history.pop_front();
}
self.action_history.push_back(sig);
alerts
}
pub fn reset(&mut self) {
self.screen_hashes.clear();
self.action_history.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_elements(text: &str) -> Vec<UIElement> {
vec![UIElement {
text: text.into(),
center: [100, 200],
action: "tap".into(),
class: None,
id: Some("btn".into()),
hint: None,
enabled: true,
checked: false,
focused: false,
editable: false,
scrollable: false,
score: 10,
}]
}
#[test]
fn test_hash_screen_deterministic() {
let elems = make_elements("OK");
let h1 = StuckDetector::hash_screen(&elems);
let h2 = StuckDetector::hash_screen(&elems);
assert_eq!(h1, h2);
}
#[test]
fn test_hash_screen_differs() {
let h1 = StuckDetector::hash_screen(&make_elements("OK"));
let h2 = StuckDetector::hash_screen(&make_elements("Cancel"));
assert_ne!(h1, h2);
}
#[test]
fn test_screen_unchanged_alert() {
let mut detector = StuckDetector::default();
let elems = make_elements("Same");
let alerts = detector.observe_screen(&elems);
assert!(alerts.is_empty());
let alerts = detector.observe_screen(&elems);
assert!(alerts.is_empty());
let alerts = detector.observe_screen(&elems);
assert_eq!(alerts.len(), 1);
assert!(matches!(alerts[0], StuckAlert::ScreenUnchanged(_)));
}
#[test]
fn test_screen_changed_resets_count() {
let mut detector = StuckDetector::default();
detector.observe_screen(&make_elements("A"));
detector.observe_screen(&make_elements("A"));
detector.observe_screen(&make_elements("B"));
let alerts = detector.observe_screen(&make_elements("A"));
assert!(alerts.is_empty());
}
#[test]
fn test_action_repeated_alert() {
let mut detector = StuckDetector::default();
detector.observe_action("tap");
detector.observe_action("tap");
let alerts = detector.observe_action("tap");
assert_eq!(alerts.len(), 1);
assert!(matches!(alerts[0], StuckAlert::ActionRepeated(_)));
}
#[test]
fn test_action_mixed_no_alert() {
let mut detector = StuckDetector::default();
let alerts1 = detector.observe_action("tap");
let alerts2 = detector.observe_action("type");
let alerts3 = detector.observe_action("scroll");
assert!(alerts1.is_empty());
assert!(alerts2.is_empty());
assert!(alerts3.is_empty());
}
#[test]
fn test_navigation_drift_alert() {
let mut detector = StuckDetector::default();
detector.observe_action("back");
detector.observe_action("home");
detector.observe_action("back");
let alerts = detector.observe_action("back");
assert!(alerts
.iter()
.any(|a| matches!(a, StuckAlert::NavigationDrift(_))));
}
#[test]
fn test_reset_clears_history() {
let mut detector = StuckDetector::default();
detector.observe_screen(&make_elements("Same"));
detector.observe_screen(&make_elements("Same"));
detector.observe_action("tap");
detector.observe_action("tap");
detector.reset();
let alerts = detector.observe_screen(&make_elements("Same"));
assert!(alerts.is_empty());
let alerts = detector.observe_action("tap");
assert!(alerts.is_empty());
}
#[test]
fn test_history_capacity() {
let mut detector = StuckDetector::default();
for i in 0..20 {
detector.observe_screen(&make_elements(&format!("elem_{}", i)));
detector.observe_action(&format!("action_{}", i));
}
assert!(detector.screen_hashes.len() <= SCREEN_HISTORY_SIZE);
assert!(detector.action_history.len() <= ACTION_HISTORY_SIZE);
}
}