use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Observation {
ReportingDisabled,
ReadFailed,
NoBaseline,
Compared {
document_changed: bool,
identity_known: bool,
edits: usize,
moved: usize,
focus_moved: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Verdict {
Changed,
Navigated,
Unchanged,
Unknown,
NotChecked,
}
impl Verdict {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Changed => "changed",
Self::Navigated => "navigated",
Self::Unchanged => "unchanged",
Self::Unknown => "unknown",
Self::NotChecked => "not_checked",
}
}
}
impl fmt::Display for Verdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Assessment {
pub verdict: Verdict,
pub reason: &'static str,
}
#[must_use]
pub const fn classify(observation: Observation) -> Assessment {
let (verdict, reason) = match observation {
Observation::ReportingDisabled => (Verdict::NotChecked, "reporting_disabled"),
Observation::ReadFailed => (Verdict::Unknown, "read_failed"),
Observation::NoBaseline => (Verdict::Unknown, "no_baseline"),
Observation::Compared { identity_known: false, .. } => (Verdict::Unknown, "identity_unreadable"),
Observation::Compared { document_changed: true, .. } => (Verdict::Navigated, "document_replaced"),
Observation::Compared { edits, .. } if edits > 0 => (Verdict::Changed, "tree_delta"),
Observation::Compared { moved, .. } if moved > 0 => (Verdict::Changed, "nodes_moved"),
Observation::Compared { focus_moved: true, .. } => (Verdict::Changed, "focus_only"),
Observation::Compared { .. } => (Verdict::Unchanged, "identical_tree"),
};
Assessment { verdict, reason }
}
#[must_use]
pub fn hint_for(assessment: Assessment) -> Option<&'static str> {
match assessment.reason {
"no_baseline" => Some(
"No snapshot existed before this action, so nothing could be compared. Run `inspect` to establish one; the next action on this page will report what changed.",
),
"read_failed" => Some(
"The action ran, but reading the page afterwards failed, so what it did is unknown. Run `inspect` to see the current state.",
),
"identical_tree" => Some(
"Nothing in the accessibility tree changed while this was watched. That is not the same as the action having no effect: a click absorbed by an overlay, an effect the tree cannot see (canvas, styling), and a handler that runs after the window all look like this. Confirm with `inspect` or `eval` before repeating the action — a repeat is a second real action.",
),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
const fn compared(edits: usize, moved: usize, focus_moved: bool) -> Observation {
Observation::Compared {
document_changed: false,
identity_known: true,
edits,
moved,
focus_moved,
}
}
#[test]
fn every_silent_case_names_itself() {
let cases = [
(Observation::ReportingDisabled, Verdict::NotChecked, "reporting_disabled"),
(Observation::ReadFailed, Verdict::Unknown, "read_failed"),
(Observation::NoBaseline, Verdict::Unknown, "no_baseline"),
(compared(0, 0, false), Verdict::Unchanged, "identical_tree"),
];
let mut seen = std::collections::HashSet::new();
for (observation, verdict, reason) in cases {
let got = classify(observation);
assert_eq!(got.verdict, verdict, "for {observation:?}");
assert_eq!(got.reason, reason, "for {observation:?}");
assert!(seen.insert((got.verdict, got.reason)), "two silences share a name: {got:?}");
}
}
#[test]
fn an_unreadable_identity_is_unknown_whatever_the_counts_say() {
let got = classify(Observation::Compared {
document_changed: false,
identity_known: false,
edits: 40,
moved: 3,
focus_moved: true,
});
assert_eq!(got.verdict, Verdict::Unknown);
assert_eq!(got.reason, "identity_unreadable");
}
#[test]
fn a_replaced_document_is_navigated_not_changed() {
let got = classify(Observation::Compared {
document_changed: true,
identity_known: true,
edits: 0,
moved: 0,
focus_moved: false,
});
assert_eq!(got.verdict, Verdict::Navigated);
}
#[test]
fn edits_and_reorders_both_count_as_changed() {
assert_eq!(classify(compared(1, 0, false)).reason, "tree_delta");
assert_eq!(classify(compared(0, 2, false)).reason, "nodes_moved");
}
#[test]
fn a_focus_move_alone_is_still_something_we_saw() {
let got = classify(compared(0, 0, true));
assert_eq!(got.verdict, Verdict::Changed);
assert_eq!(got.reason, "focus_only");
}
#[test]
fn no_verdict_claims_the_action_had_no_effect() {
for observation in [
Observation::ReportingDisabled,
Observation::ReadFailed,
Observation::NoBaseline,
compared(0, 0, false),
compared(1, 0, false),
] {
let got = classify(observation);
assert_ne!(got.verdict.as_str(), "no_effect", "for {observation:?}");
}
}
#[test]
fn each_uncertain_verdict_carries_a_way_forward() {
for observation in [Observation::ReadFailed, Observation::NoBaseline, compared(0, 0, false)] {
let assessment = classify(observation);
assert!(hint_for(assessment).is_some(), "no hint for {observation:?}");
}
}
}