Skip to main content

browser_commander/interactions/
click_result.rs

1//! Truthful result model for click operations.
2//!
3//! The old model answered every click with two booleans, and both of them were
4//! optimistic: a button that did nothing still reported `verified: true`. This
5//! model separates three questions that used to be conflated - did we dispatch
6//! the click, did the page react, and how did the operation end - and records
7//! the evidence behind each answer.
8
9use std::fmt;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde_json::{json, Value};
14
15/// How a click operation ended.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ClickStatus {
18    /// The click was dispatched and its effect was confirmed.
19    Succeeded,
20    /// The click could not be dispatched, or dispatch provably failed.
21    Failed,
22    /// The operation ran out of its budget.
23    TimedOut,
24    /// Navigation or an explicit stop cut the operation short.
25    Interrupted,
26    /// The click was dispatched, but nothing confirmed or denied an effect.
27    Unverified,
28}
29
30impl fmt::Display for ClickStatus {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        let text = match self {
33            Self::Succeeded => "succeeded",
34            Self::Failed => "failed",
35            Self::TimedOut => "timed_out",
36            Self::Interrupted => "interrupted",
37            Self::Unverified => "unverified",
38        };
39        write!(f, "{text}")
40    }
41}
42
43/// What the page did in response to the click.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ClickEffect {
46    /// Evidence was observed that the click did something.
47    Confirmed,
48    /// No evidence either way.
49    NotObserved,
50    /// Evidence was observed that the click did *not* do what was expected.
51    Contradicted,
52}
53
54impl fmt::Display for ClickEffect {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        let text = match self {
57            Self::Confirmed => "confirmed",
58            Self::NotObserved => "not-observed",
59            Self::Contradicted => "contradicted",
60        };
61        write!(f, "{text}")
62    }
63}
64
65/// One observation behind a click verdict.
66///
67/// Evidence is structured rather than prose so that callers can assert on it
68/// and so that a trace can carry it without re-parsing a sentence.
69#[derive(Debug, Clone, PartialEq)]
70pub struct Evidence {
71    /// Evidence kind, for example `element-state` or `navigation`.
72    pub kind: String,
73    /// Structured detail describing the observation.
74    pub detail: Value,
75}
76
77impl Evidence {
78    /// Build one piece of evidence.
79    pub fn new(kind: impl Into<String>, detail: Value) -> Self {
80        Self {
81            kind: kind.into(),
82            detail,
83        }
84    }
85
86    /// Build evidence whose only detail is a message.
87    pub fn message(kind: impl Into<String>, message: impl Into<String>) -> Self {
88        Self::new(kind, json!({ "message": message.into() }))
89    }
90}
91
92static ACTION_COUNTER: AtomicU64 = AtomicU64::new(0);
93
94/// Produce a correlation ID for a single click action.
95///
96/// Navigation evidence is only meaningful when it can be tied back to the click
97/// that is supposed to have caused it, so every click carries one of these.
98pub fn next_action_id() -> String {
99    let stamp = SystemTime::now()
100        .duration_since(UNIX_EPOCH)
101        .map(|d| d.as_millis())
102        .unwrap_or(0);
103    let seq = ACTION_COUNTER.fetch_add(1, Ordering::Relaxed);
104    format!("click-{stamp:x}-{seq:06x}")
105}
106
107/// Result of a click operation.
108///
109/// `clicked` and `verified` are retained for callers written against the old
110/// API, but they are now *derived* from the honest fields rather than being set
111/// optimistically: `verified` is true only when the effect was confirmed.
112#[derive(Debug, Clone)]
113pub struct ClickResult {
114    /// How the operation ended.
115    pub status: ClickStatus,
116    /// Whether the click was actually delivered to the page.
117    pub dispatched: bool,
118    /// What the page was observed to do.
119    pub effect: ClickEffect,
120    /// Whether a navigation was observed around the click.
121    pub navigated: bool,
122    /// The reason for the result.
123    pub reason: String,
124    /// How long the operation took.
125    pub elapsed_ms: u128,
126    /// Observations behind the verdict.
127    pub evidence: Vec<Evidence>,
128    /// Correlation ID for this click.
129    pub action_id: String,
130    /// Legacy alias for [`ClickResult::dispatched`].
131    pub clicked: bool,
132    /// Legacy alias for "the effect was confirmed".
133    pub verified: bool,
134}
135
136impl ClickResult {
137    /// Build a result, deriving the legacy booleans from the honest fields.
138    pub fn new(
139        status: ClickStatus,
140        dispatched: bool,
141        effect: ClickEffect,
142        reason: impl Into<String>,
143    ) -> Self {
144        Self {
145            status,
146            dispatched,
147            effect,
148            navigated: false,
149            reason: reason.into(),
150            elapsed_ms: 0,
151            evidence: Vec::new(),
152            action_id: next_action_id(),
153            clicked: dispatched,
154            verified: effect == ClickEffect::Confirmed,
155        }
156    }
157
158    /// Attach the observations behind this verdict.
159    #[must_use]
160    pub fn with_evidence(mut self, evidence: Vec<Evidence>) -> Self {
161        self.evidence = evidence;
162        self
163    }
164
165    /// Record how long the operation took.
166    #[must_use]
167    pub fn with_elapsed_ms(mut self, elapsed_ms: u128) -> Self {
168        self.elapsed_ms = elapsed_ms;
169        self
170    }
171
172    /// Record that a navigation was observed.
173    #[must_use]
174    pub fn with_navigated(mut self, navigated: bool) -> Self {
175        self.navigated = navigated;
176        self
177    }
178
179    /// Reuse an existing correlation ID.
180    #[must_use]
181    pub fn with_action_id(mut self, action_id: impl Into<String>) -> Self {
182        self.action_id = action_id.into();
183        self
184    }
185
186    /// A click whose effect was confirmed.
187    pub fn success(reason: impl Into<String>) -> Self {
188        Self::new(ClickStatus::Succeeded, true, ClickEffect::Confirmed, reason)
189    }
190
191    /// A click that was delivered but whose effect nothing confirmed.
192    ///
193    /// This replaces the old "assumed success" verdict.
194    pub fn unverified(reason: impl Into<String>) -> Self {
195        Self::new(
196            ClickStatus::Unverified,
197            true,
198            ClickEffect::NotObserved,
199            reason,
200        )
201    }
202
203    /// A click cut short by navigation.
204    ///
205    /// Navigation is *not* evidence that this click caused it: the navigation
206    /// may well have been in flight before the click was dispatched.
207    pub fn navigation(reason: impl Into<String>) -> Self {
208        Self::new(
209            ClickStatus::Interrupted,
210            false,
211            ClickEffect::NotObserved,
212            reason,
213        )
214        .with_navigated(true)
215    }
216
217    /// A click that could not be delivered.
218    pub fn failed(reason: impl Into<String>) -> Self {
219        Self::new(ClickStatus::Failed, false, ClickEffect::NotObserved, reason)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn success_confirms_the_effect() {
229        let result = ClickResult::success("element clicked");
230        assert!(result.dispatched);
231        assert!(result.clicked);
232        assert!(result.verified);
233        assert_eq!(result.status, ClickStatus::Succeeded);
234        assert_eq!(result.effect, ClickEffect::Confirmed);
235    }
236
237    #[test]
238    fn unverified_never_claims_verification() {
239        // Regression test for issue #89: a dispatched click with no observed
240        // effect used to report `verified: true`.
241        let result = ClickResult::unverified("no observable change");
242        assert!(result.dispatched);
243        assert!(result.clicked);
244        assert!(!result.verified);
245        assert_eq!(result.status, ClickStatus::Unverified);
246        assert_eq!(result.effect, ClickEffect::NotObserved);
247    }
248
249    #[test]
250    fn navigation_is_not_proof_of_effect() {
251        // Regression test for issue #89: navigation used to set `verified: true`
252        // even though it may have been in flight before the click.
253        let result = ClickResult::navigation("page navigated");
254        assert!(!result.clicked);
255        assert!(!result.verified);
256        assert!(result.navigated);
257        assert_eq!(result.status, ClickStatus::Interrupted);
258        assert_eq!(result.effect, ClickEffect::NotObserved);
259    }
260
261    #[test]
262    fn failed_reports_no_dispatch() {
263        let result = ClickResult::failed("element not found");
264        assert!(!result.clicked);
265        assert!(!result.verified);
266        assert!(!result.navigated);
267        assert_eq!(result.status, ClickStatus::Failed);
268    }
269
270    #[test]
271    fn statuses_and_effects_render_the_documented_names() {
272        assert_eq!(ClickStatus::TimedOut.to_string(), "timed_out");
273        assert_eq!(ClickStatus::Interrupted.to_string(), "interrupted");
274        assert_eq!(ClickEffect::NotObserved.to_string(), "not-observed");
275        assert_eq!(ClickEffect::Contradicted.to_string(), "contradicted");
276    }
277
278    #[test]
279    fn action_ids_are_unique_per_click() {
280        let a = next_action_id();
281        let b = next_action_id();
282        assert_ne!(a, b);
283        assert!(a.starts_with("click-"));
284    }
285
286    #[test]
287    fn evidence_carries_structured_detail() {
288        let item = Evidence::new("element-state", json!({"key": "checked", "after": true}));
289        assert_eq!(item.kind, "element-state");
290        assert_eq!(item.detail["key"], "checked");
291    }
292}