browser_commander/interactions/
click_result.rs1use std::fmt;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde_json::{json, Value};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ClickStatus {
18 Succeeded,
20 Failed,
22 TimedOut,
24 Interrupted,
26 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ClickEffect {
46 Confirmed,
48 NotObserved,
50 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#[derive(Debug, Clone, PartialEq)]
70pub struct Evidence {
71 pub kind: String,
73 pub detail: Value,
75}
76
77impl Evidence {
78 pub fn new(kind: impl Into<String>, detail: Value) -> Self {
80 Self {
81 kind: kind.into(),
82 detail,
83 }
84 }
85
86 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
94pub 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#[derive(Debug, Clone)]
113pub struct ClickResult {
114 pub status: ClickStatus,
116 pub dispatched: bool,
118 pub effect: ClickEffect,
120 pub navigated: bool,
122 pub reason: String,
124 pub elapsed_ms: u128,
126 pub evidence: Vec<Evidence>,
128 pub action_id: String,
130 pub clicked: bool,
132 pub verified: bool,
134}
135
136impl ClickResult {
137 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 #[must_use]
160 pub fn with_evidence(mut self, evidence: Vec<Evidence>) -> Self {
161 self.evidence = evidence;
162 self
163 }
164
165 #[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 #[must_use]
174 pub fn with_navigated(mut self, navigated: bool) -> Self {
175 self.navigated = navigated;
176 self
177 }
178
179 #[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 pub fn success(reason: impl Into<String>) -> Self {
188 Self::new(ClickStatus::Succeeded, true, ClickEffect::Confirmed, reason)
189 }
190
191 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 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 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 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 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}