use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClickStatus {
Succeeded,
Failed,
TimedOut,
Interrupted,
Unverified,
}
impl fmt::Display for ClickStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::TimedOut => "timed_out",
Self::Interrupted => "interrupted",
Self::Unverified => "unverified",
};
write!(f, "{text}")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClickEffect {
Confirmed,
NotObserved,
Contradicted,
}
impl fmt::Display for ClickEffect {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let text = match self {
Self::Confirmed => "confirmed",
Self::NotObserved => "not-observed",
Self::Contradicted => "contradicted",
};
write!(f, "{text}")
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Evidence {
pub kind: String,
pub detail: Value,
}
impl Evidence {
pub fn new(kind: impl Into<String>, detail: Value) -> Self {
Self {
kind: kind.into(),
detail,
}
}
pub fn message(kind: impl Into<String>, message: impl Into<String>) -> Self {
Self::new(kind, json!({ "message": message.into() }))
}
}
static ACTION_COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn next_action_id() -> String {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let seq = ACTION_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("click-{stamp:x}-{seq:06x}")
}
#[derive(Debug, Clone)]
pub struct ClickResult {
pub status: ClickStatus,
pub dispatched: bool,
pub effect: ClickEffect,
pub navigated: bool,
pub reason: String,
pub elapsed_ms: u128,
pub evidence: Vec<Evidence>,
pub action_id: String,
pub clicked: bool,
pub verified: bool,
}
impl ClickResult {
pub fn new(
status: ClickStatus,
dispatched: bool,
effect: ClickEffect,
reason: impl Into<String>,
) -> Self {
Self {
status,
dispatched,
effect,
navigated: false,
reason: reason.into(),
elapsed_ms: 0,
evidence: Vec::new(),
action_id: next_action_id(),
clicked: dispatched,
verified: effect == ClickEffect::Confirmed,
}
}
#[must_use]
pub fn with_evidence(mut self, evidence: Vec<Evidence>) -> Self {
self.evidence = evidence;
self
}
#[must_use]
pub fn with_elapsed_ms(mut self, elapsed_ms: u128) -> Self {
self.elapsed_ms = elapsed_ms;
self
}
#[must_use]
pub fn with_navigated(mut self, navigated: bool) -> Self {
self.navigated = navigated;
self
}
#[must_use]
pub fn with_action_id(mut self, action_id: impl Into<String>) -> Self {
self.action_id = action_id.into();
self
}
pub fn success(reason: impl Into<String>) -> Self {
Self::new(ClickStatus::Succeeded, true, ClickEffect::Confirmed, reason)
}
pub fn unverified(reason: impl Into<String>) -> Self {
Self::new(
ClickStatus::Unverified,
true,
ClickEffect::NotObserved,
reason,
)
}
pub fn navigation(reason: impl Into<String>) -> Self {
Self::new(
ClickStatus::Interrupted,
false,
ClickEffect::NotObserved,
reason,
)
.with_navigated(true)
}
pub fn failed(reason: impl Into<String>) -> Self {
Self::new(ClickStatus::Failed, false, ClickEffect::NotObserved, reason)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_confirms_the_effect() {
let result = ClickResult::success("element clicked");
assert!(result.dispatched);
assert!(result.clicked);
assert!(result.verified);
assert_eq!(result.status, ClickStatus::Succeeded);
assert_eq!(result.effect, ClickEffect::Confirmed);
}
#[test]
fn unverified_never_claims_verification() {
let result = ClickResult::unverified("no observable change");
assert!(result.dispatched);
assert!(result.clicked);
assert!(!result.verified);
assert_eq!(result.status, ClickStatus::Unverified);
assert_eq!(result.effect, ClickEffect::NotObserved);
}
#[test]
fn navigation_is_not_proof_of_effect() {
let result = ClickResult::navigation("page navigated");
assert!(!result.clicked);
assert!(!result.verified);
assert!(result.navigated);
assert_eq!(result.status, ClickStatus::Interrupted);
assert_eq!(result.effect, ClickEffect::NotObserved);
}
#[test]
fn failed_reports_no_dispatch() {
let result = ClickResult::failed("element not found");
assert!(!result.clicked);
assert!(!result.verified);
assert!(!result.navigated);
assert_eq!(result.status, ClickStatus::Failed);
}
#[test]
fn statuses_and_effects_render_the_documented_names() {
assert_eq!(ClickStatus::TimedOut.to_string(), "timed_out");
assert_eq!(ClickStatus::Interrupted.to_string(), "interrupted");
assert_eq!(ClickEffect::NotObserved.to_string(), "not-observed");
assert_eq!(ClickEffect::Contradicted.to_string(), "contradicted");
}
#[test]
fn action_ids_are_unique_per_click() {
let a = next_action_id();
let b = next_action_id();
assert_ne!(a, b);
assert!(a.starts_with("click-"));
}
#[test]
fn evidence_carries_structured_detail() {
let item = Evidence::new("element-state", json!({"key": "checked", "after": true}));
assert_eq!(item.kind, "element-state");
assert_eq!(item.detail["key"], "checked");
}
}