diff --git a/crates/core/src/ref_action_wait_lease_tests.rs b/crates/core/src/ref_action_wait_lease_tests.rs new file mode 100644 index 0000000..e5c4de3 --- /dev/null +++ b/crates/core/src/ref_action_wait_lease_tests.rs @@ -0,0 +1,197 @@ +use super::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU32, Ordering}, +}; + +struct DoubleCheckAdapter { + live_calls: AtomicU32, + lease_held: Arc, + prelease_calls: u32, + timeout_ms: u64, + expected_deadline: Option, +} + +struct DoubleCheckLeaseGuard(Arc); + +impl Drop for DoubleCheckLeaseGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::SeqCst); + } +} + +impl ObservationOps for DoubleCheckAdapter { + fn resolve_element_strict( + &self, + _entry: &RefEntry, + _deadline: crate::Deadline, + ) -> Result { + Ok(NativeHandle::null()) + } + + fn get_live_element( + &self, + _handle: &NativeHandle, + _deadline: crate::Deadline, + ) -> Result { + let call = self.live_calls.fetch_add(1, Ordering::SeqCst) + 1; + assert_eq!( + self.lease_held.load(Ordering::SeqCst), + call > self.prelease_calls, + "poll reads must be lease-free and final validation must be leased" + ); + let states = if call <= self.prelease_calls { + Vec::new() + } else { + vec!["disabled".to_string()] + }; + Ok(crate::adapter::LiveElement { + identity: crate::adapter::live_identity("Run"), + state: crate::element_state::ElementState { + role: "button".into(), + states, + value: None, + enabled: Some(true), + hidden: Some(false), + offscreen: Some(false), + }, + states_complete: true, + bounds: Some(bounds()), + available_actions: vec![capability::CLICK.into()], + }) + } + + fn get_element_bounds( + &self, + _handle: &NativeHandle, + _deadline: crate::Deadline, + ) -> Result, AdapterError> { + Ok(Some(bounds())) + } + + fn hit_test( + &self, + _handle: &NativeHandle, + _point: crate::Point, + _deadline: crate::Deadline, + ) -> Result { + Ok(crate::hit_test::HitTestResult::ReachesTarget) + } +} + +impl ActionOps for DoubleCheckAdapter { + fn execute_action( + &self, + _handle: &NativeHandle, + _request: ActionRequest, + _lease: &crate::InteractionLease, + ) -> Result { + Ok(crate::action_result::ActionResult::delivered_unverified( + "click", + )) + } +} + +impl InputOps for DoubleCheckAdapter {} + +impl SystemOps for DoubleCheckAdapter { + crate::adapter::exact_window_focus!(); + + fn acquire_interaction_lease( + &self, + deadline: crate::Deadline, + ) -> Result { + assert_eq!(self.live_calls.load(Ordering::SeqCst), self.prelease_calls); + assert_eq!(deadline.timeout_ms(), self.timeout_ms); + if let Some(expected) = self.expected_deadline { + assert_eq!(deadline, expected); + return Err(AdapterError::new(ErrorCode::Internal, "lease probe")); + } + self.lease_held.store(true, Ordering::SeqCst); + Ok(crate::InteractionLease::guarded_with_contention( + deadline, + DoubleCheckLeaseGuard(Arc::clone(&self.lease_held)), + 3, + )) + } +} + +#[test] +fn stability_revalidates_once_under_lease_before_dispatch() { + let adapter = adapter(2, 5_000, None); + let result = run(&adapter, ActionRequest::headed(Action::DoubleClick), 5_000); + + assert_eq!(result.action, "click"); + assert_eq!(adapter.live_calls.load(Ordering::SeqCst), 4); + let metrics = &result.details.as_ref().unwrap()["auto_wait"]; + assert_eq!(metrics["read_only_resolve_attempts"], 2); + assert_eq!(metrics["read_only_preflight_attempts"], 2); + assert_eq!(metrics["lease_contention_count"], 3); + assert!(metrics["lease_hold_ms"].as_u64().is_some()); + assert!(!adapter.lease_held.load(Ordering::SeqCst)); +} + +#[test] +fn lease_acquisition_receives_the_exact_original_deadline() { + let deadline = crate::Deadline::after(80).unwrap(); + let adapter = adapter(0, 80, Some(deadline)); + let error = super::super::execute_with_deadline( + RefActionWaitCtx { + adapter: &adapter, + entry: &entry(), + ref_id: "@e1", + context: &CommandContext::default(), + }, + ActionRequest::headless(Action::Click), + deadline, + crate::ref_action::dispatch_resolved, + ) + .err() + .unwrap(); + + assert_eq!(error.code, ErrorCode::Internal); + assert_eq!(error.message, "lease probe"); + assert_eq!(adapter.live_calls.load(Ordering::SeqCst), 0); + assert!(!adapter.lease_held.load(Ordering::SeqCst)); +} + +fn adapter( + prelease_calls: u32, + timeout_ms: u64, + expected_deadline: Option, +) -> DoubleCheckAdapter { + DoubleCheckAdapter { + live_calls: AtomicU32::new(0), + lease_held: Arc::new(AtomicBool::new(false)), + prelease_calls, + timeout_ms, + expected_deadline, + } +} + +fn run( + adapter: &DoubleCheckAdapter, + request: ActionRequest, + timeout_ms: u64, +) -> crate::action_result::ActionResult { + execute_with_auto_wait( + RefActionWaitCtx { + adapter, + entry: &entry(), + ref_id: "@e1", + context: &CommandContext::default(), + }, + request.with_timeout_ms(Some(timeout_ms)), + crate::ref_action::dispatch_resolved, + ) + .unwrap() +} + +fn bounds() -> crate::Rect { + crate::Rect { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + } +} diff --git a/crates/core/src/ref_action_wait_tests.rs b/crates/core/src/ref_action_wait_tests.rs index f6b8455..f8b8d8b 100644 --- a/crates/core/src/ref_action_wait_tests.rs +++ b/crates/core/src/ref_action_wait_tests.rs @@ -5,10 +5,7 @@ use crate::{ adapter::{ActionOps, InputOps, NativeHandle, ObservationOps, SystemOps}, capability, }; -use std::sync::{ - Arc, - atomic::{AtomicBool, AtomicU32, Ordering}, -}; +use std::sync::atomic::{AtomicU32, Ordering}; #[test] fn oversized_timeout_budget_is_clamped_and_never_overflows() { @@ -231,151 +228,8 @@ fn transient_ambiguity_is_recorded_in_result_details() { ); } -/// Lease-free polling is followed by physical-pointer preflight and final -/// stability validation under the interaction lease before dispatch. -struct DoubleCheckAdapter { - live_calls: AtomicU32, - lease_held: Arc, -} - -struct DoubleCheckLeaseGuard(Arc); - -impl Drop for DoubleCheckLeaseGuard { - fn drop(&mut self) { - self.0.store(false, Ordering::SeqCst); - } -} - -impl ObservationOps for DoubleCheckAdapter { - fn resolve_element_strict( - &self, - _entry: &RefEntry, - _deadline: crate::Deadline, - ) -> Result { - Ok(NativeHandle::null()) - } - - fn get_live_element( - &self, - _handle: &NativeHandle, - _deadline: crate::Deadline, - ) -> Result { - let call = self.live_calls.fetch_add(1, Ordering::SeqCst) + 1; - assert_eq!( - self.lease_held.load(Ordering::SeqCst), - call > 2, - "poll reads must be lease-free and final validation must be leased" - ); - let states = if call <= 2 { - vec![] - } else { - vec!["disabled".to_string()] - }; - Ok(crate::adapter::LiveElement { - identity: crate::adapter::live_identity("Run"), - state: crate::element_state::ElementState { - role: "button".into(), - states, - value: None, - enabled: Some(true), - hidden: Some(false), - offscreen: Some(false), - }, - states_complete: true, - bounds: Some(crate::Rect { - x: 0.0, - y: 0.0, - width: 10.0, - height: 10.0, - }), - available_actions: vec![capability::CLICK.into()], - }) - } - - fn get_element_bounds( - &self, - _handle: &NativeHandle, - _deadline: crate::Deadline, - ) -> Result, AdapterError> { - Ok(Some(crate::Rect { - x: 0.0, - y: 0.0, - width: 10.0, - height: 10.0, - })) - } - - fn hit_test( - &self, - _handle: &NativeHandle, - _point: crate::Point, - _deadline: crate::Deadline, - ) -> Result { - Ok(crate::hit_test::HitTestResult::ReachesTarget) - } -} - -impl ActionOps for DoubleCheckAdapter { - fn execute_action( - &self, - _handle: &NativeHandle, - _request: ActionRequest, - _lease: &crate::InteractionLease, - ) -> Result { - Ok(crate::action_result::ActionResult::delivered_unverified( - "click", - )) - } -} - -impl InputOps for DoubleCheckAdapter {} - -impl SystemOps for DoubleCheckAdapter { - crate::adapter::exact_window_focus!(); - - fn acquire_interaction_lease( - &self, - deadline: crate::Deadline, - ) -> Result { - assert_eq!(self.live_calls.load(Ordering::SeqCst), 2); - assert_eq!(deadline.timeout_ms(), 80); - self.lease_held.store(true, Ordering::SeqCst); - Ok(crate::InteractionLease::guarded_with_contention( - deadline, - DoubleCheckLeaseGuard(Arc::clone(&self.lease_held)), - 3, - )) - } -} - -#[test] -fn stability_revalidates_once_under_lease_before_dispatch() { - let adapter = DoubleCheckAdapter { - live_calls: AtomicU32::new(0), - lease_held: Arc::new(AtomicBool::new(false)), - }; - - let result = execute_with_auto_wait( - RefActionWaitCtx { - adapter: &adapter, - entry: &entry(), - ref_id: "@e1", - context: &CommandContext::default(), - }, - ActionRequest::headed(Action::DoubleClick).with_timeout_ms(Some(80)), - crate::ref_action::dispatch_resolved, - ) - .unwrap(); - - assert_eq!(result.action, "click"); - assert_eq!(adapter.live_calls.load(Ordering::SeqCst), 4); - let metrics = &result.details.as_ref().unwrap()["auto_wait"]; - assert_eq!(metrics["read_only_resolve_attempts"], 2); - assert_eq!(metrics["read_only_preflight_attempts"], 2); - assert_eq!(metrics["lease_contention_count"], 3); - assert!(metrics["lease_hold_ms"].as_u64().is_some()); - assert!(!adapter.lease_held.load(Ordering::SeqCst)); -} +#[path = "ref_action_wait_lease_tests.rs"] +mod lease_tests; #[path = "ref_action_wait_unresponsive_tests.rs"] mod unresponsive_tests;