use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Verdict {
Changed,
Navigated,
Intercepted,
NotKept,
NoEffect,
Unchanged,
Unknown,
NotChecked,
}
impl Verdict {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Changed => "changed",
Self::Navigated => "navigated",
Self::Intercepted => "intercepted",
Self::NotKept => "not_kept",
Self::NoEffect => "no_effect",
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,
pub page: PageSight,
}
#[must_use]
pub const fn classify(
observation: Observation,
delivered: Delivered,
postcondition: Postcondition,
) -> Assessment {
let (verdict, reason) = match (observation, delivered.how) {
(_, Delivery::NotSettled) => (Verdict::Unknown, "scroll_not_settled"),
(_, Delivery::OffTarget) => (Verdict::Unknown, "aim_point_off_target"),
(_, Delivery::Intercepted) => (
Verdict::Intercepted,
if delivered.modal_receiver { "modal_dialog" } else { "hit_test_receiver" },
),
(_, _) if matches!(postcondition, Postcondition::Discarded) => {
(Verdict::NotKept, "value_reverted")
}
(_, _) if matches!(postcondition, Postcondition::Rewritten) => {
(Verdict::NotKept, "value_rewritten")
}
(Observation::Compared { identity_known: false, .. }, _) => (Verdict::Unknown, "identity_unreadable"),
(Observation::Compared { document_changed: true, .. }, _) => (Verdict::Navigated, "document_replaced"),
(Observation::Compared { values_lost, .. }, _) if values_lost > 0 => {
(Verdict::Changed, "values_lost")
}
(Observation::Compared { edits, .. }, _) if edits > 0 => (Verdict::Changed, "tree_delta"),
(Observation::Compared { moved, .. }, _) if moved > 0 => (Verdict::Changed, "nodes_moved"),
(_, _) if matches!(postcondition, Postcondition::Kept) => (Verdict::Changed, "value_kept"),
(Observation::ReportingDisabled, _) => (Verdict::NotChecked, "reporting_disabled"),
(Observation::ReadFailed, _) => (Verdict::Unknown, "read_failed"),
(Observation::NoBaseline, _) => (Verdict::Unknown, "no_baseline"),
(Observation::Compared { edits: 0, moved: 0, .. }, Delivery::TargetHit)
if delivered.observed_after_ms.is_some() =>
{
(Verdict::NoEffect, "delivered_no_change")
}
(Observation::Compared { focus_moved: true, .. }, _) => (Verdict::Changed, "focus_only"),
(Observation::Compared { .. }, _) => (Verdict::Unchanged, "identical_tree"),
};
let page = match observation {
Observation::ReadFailed => PageSight::Unreadable,
Observation::ReportingDisabled | Observation::NoBaseline | Observation::Compared { .. } => {
PageSight::Readable
}
};
Assessment { verdict, reason, page }
}
pub use crate::verdict_words::{gloss, hint_for, next_for, short_hint};
pub use crate::verdict_evidence::{Delivered, Delivery, Observation, PageSight, Postcondition};
#[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,
values_lost: 0,
}
}
const fn hit(observed_after_ms: u64) -> Delivered {
Delivered {
how: Delivery::TargetHit,
modal_receiver: false,
observed_after_ms: Some(observed_after_ms),
}
}
const fn intercepted(modal_receiver: bool) -> Delivered {
Delivered { how: Delivery::Intercepted, modal_receiver, observed_after_ms: None }
}
fn plain(observation: Observation) -> Assessment {
classify(observation, Delivered::NOT_PROBED, Postcondition::NotRead)
}
fn mouse(observation: Observation, delivered: Delivered) -> Assessment {
classify(observation, delivered, Postcondition::NotRead)
}
fn reverted(observation: Observation) -> Assessment {
classify(observation, Delivered::NOT_PROBED, Postcondition::Discarded)
}
#[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 = plain(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 = plain(Observation::Compared {
document_changed: false,
identity_known: false,
edits: 40,
moved: 3,
focus_moved: true,
values_lost: 0,
});
assert_eq!(got.verdict, Verdict::Unknown);
assert_eq!(got.reason, "identity_unreadable");
}
#[test]
fn a_replaced_document_is_navigated_not_changed() {
let got = plain(Observation::Compared {
document_changed: true,
identity_known: true,
edits: 0,
moved: 0,
focus_moved: false,
values_lost: 0,
});
assert_eq!(got.verdict, Verdict::Navigated);
}
#[test]
fn edits_and_reorders_both_count_as_changed() {
assert_eq!(plain(compared(1, 0, false)).reason, "tree_delta");
assert_eq!(plain(compared(0, 2, false)).reason, "nodes_moved");
}
#[test]
fn an_interception_outranks_the_delta_it_caused() {
for observation in [compared(4, 0, true), compared(0, 0, true), compared(0, 0, false)] {
let got = mouse(observation, intercepted(false));
assert_eq!(got.verdict, Verdict::Intercepted, "for {observation:?}");
assert_eq!(got.reason, "hit_test_receiver");
}
assert_eq!(mouse(compared(1, 0, false), intercepted(true)).reason, "modal_dialog");
}
#[test]
fn a_definite_delivery_reading_outranks_a_replaced_document() {
let navigated = Observation::Compared {
document_changed: true,
identity_known: true,
edits: 0,
moved: 0,
focus_moved: false,
values_lost: 0,
};
assert_eq!(mouse(navigated, intercepted(false)).verdict, Verdict::Intercepted);
assert_eq!(mouse(navigated, intercepted(true)).reason, "modal_dialog");
for how in [Delivery::NotSettled, Delivery::OffTarget] {
let got = mouse(navigated, Delivered { how, ..Delivered::NOT_PROBED });
assert_eq!(got.verdict, Verdict::Unknown, "for {how:?}");
assert_ne!(got.reason, "document_replaced");
}
}
#[test]
fn a_real_navigation_is_still_reported_when_delivery_proves_nothing() {
let navigated = Observation::Compared {
document_changed: true,
identity_known: true,
edits: 0,
moved: 0,
focus_moved: false,
values_lost: 0,
};
for how in [Delivery::TargetHit, Delivery::JsDispatch, Delivery::NotProbed] {
let delivered =
Delivered { how, modal_receiver: false, observed_after_ms: Some(60) };
let got = classify(navigated, delivered, Postcondition::NotRead);
assert_eq!(got.verdict, Verdict::Navigated, "for {how:?}");
assert_eq!(got.reason, "document_replaced");
}
}
#[test]
fn a_value_the_page_did_not_keep_outranks_the_delta_beside_it() {
for observation in [compared(0, 0, true), compared(4, 0, true), compared(0, 2, false)] {
let got = reverted(observation);
assert_eq!(got.verdict, Verdict::NotKept, "for {observation:?}");
assert_eq!(got.reason, "value_reverted");
}
}
#[test]
fn a_reverted_value_is_reported_whatever_the_page_read_did() {
for observation in [
Observation::ReportingDisabled,
Observation::ReadFailed,
Observation::NoBaseline,
compared(0, 0, false),
] {
let got = reverted(observation);
assert_eq!(got.verdict, Verdict::NotKept, "for {observation:?}");
assert_eq!(got.reason, "value_reverted");
}
}
#[test]
fn a_reverted_value_outranks_an_unreadable_identity_and_a_replaced_document() {
let unreadable = Observation::Compared {
document_changed: false,
identity_known: false,
edits: 0,
moved: 0,
focus_moved: false,
values_lost: 0,
};
assert_eq!(reverted(unreadable).reason, "value_reverted");
let navigated = Observation::Compared {
document_changed: true,
identity_known: true,
edits: 0,
moved: 0,
focus_moved: false,
values_lost: 0,
};
assert_eq!(reverted(navigated).verdict, Verdict::NotKept);
for how in [Delivery::NotSettled, Delivery::OffTarget, Delivery::Intercepted] {
let delivered = Delivered { how, ..Delivered::NOT_PROBED };
let got = classify(compared(0, 0, false), delivered, Postcondition::Discarded);
assert_ne!(got.verdict, Verdict::NotKept, "for {how:?}");
}
}
#[test]
fn a_visible_delta_outranks_the_read_back_that_agrees_with_it() {
let kept = |observation| classify(observation, Delivered::NOT_PROBED, Postcondition::Kept);
assert_eq!(kept(compared(3, 0, false)).reason, "tree_delta");
assert_eq!(kept(compared(0, 2, false)).reason, "nodes_moved");
let lost = Observation::Compared {
document_changed: false,
identity_known: true,
edits: 2,
moved: 0,
focus_moved: true,
values_lost: 1,
};
assert_eq!(kept(lost).reason, "values_lost");
for (document_changed, identity_known, reason) in
[(true, true, "document_replaced"), (false, false, "identity_unreadable")]
{
let observation = Observation::Compared {
document_changed,
identity_known,
edits: 0,
moved: 0,
focus_moved: true,
values_lost: 0,
};
assert_eq!(kept(observation).reason, reason);
}
}
#[test]
fn a_confirmed_write_the_tree_cannot_show_is_not_reported_as_focus_alone() {
for observation in [compared(0, 0, true), compared(0, 0, false)] {
let got = classify(observation, Delivered::NOT_PROBED, Postcondition::Kept);
assert_eq!(got.verdict, Verdict::Changed, "the write did change the page: {got:?}");
assert_eq!(got.reason, "value_kept");
assert_ne!(plain(observation).reason, got.reason, "and the old answer is gone");
}
}
#[test]
fn a_confirmed_write_outranks_every_admission_that_nothing_was_compared() {
for observation in [
Observation::ReportingDisabled,
Observation::ReadFailed,
Observation::NoBaseline,
] {
let got = classify(observation, Delivered::NOT_PROBED, Postcondition::Kept);
assert_eq!(got.verdict, Verdict::Changed, "for {observation:?}");
assert_eq!(got.reason, "value_kept", "for {observation:?}");
}
}
#[test]
fn a_confirmed_write_on_an_unread_page_still_says_inspect() {
use crate::verdict_words::Next;
let blind = classify(Observation::ReadFailed, Delivered::NOT_PROBED, Postcondition::Kept);
assert_eq!(blind.verdict, Verdict::Changed);
assert_eq!(blind.reason, "value_kept");
assert_eq!(blind.page, PageSight::Unreadable);
assert_eq!(next_for(blind), Next::Inspect, "carrying on while blind is the one refusal");
let hint = hint_for(blind).expect("the blindness has to be said somewhere");
assert!(hint.contains("inspect"), "{hint}");
assert!(hint.contains("what else moved"), "and name what is unknown: {hint}");
for observation in [
Observation::ReportingDisabled,
Observation::NoBaseline,
compared(0, 0, true),
] {
let seen = classify(observation, Delivered::NOT_PROBED, Postcondition::Kept);
assert_eq!(seen.page, PageSight::Readable, "for {observation:?}");
assert_eq!(next_for(seen), Next::Proceed, "for {observation:?}");
assert_eq!(hint_for(seen), None, "for {observation:?}");
}
}
#[test]
fn no_verdict_answers_proceed_about_a_page_it_could_not_read() {
use crate::verdict_words::Next;
for postcondition in
[Postcondition::NotRead, Postcondition::Kept, Postcondition::Discarded, Postcondition::Rewritten]
{
for how in [
Delivery::TargetHit,
Delivery::Intercepted,
Delivery::OffTarget,
Delivery::NotSettled,
Delivery::JsDispatch,
Delivery::NotProbed,
] {
let delivered = Delivered { how, modal_receiver: false, observed_after_ms: Some(60) };
let blind = classify(Observation::ReadFailed, delivered, postcondition);
assert_ne!(next_for(blind), Next::Proceed, "{} / {}", blind.verdict, blind.reason);
}
}
let not_settled = Delivered { how: Delivery::NotSettled, ..Delivered::NOT_PROBED };
let got = classify(Observation::ReadFailed, not_settled, Postcondition::NotRead);
assert_eq!(next_for(got), Next::Retry);
}
#[test]
fn a_confirmed_write_does_not_outrank_a_delivery_that_failed() {
for how in [Delivery::NotSettled, Delivery::OffTarget, Delivery::Intercepted] {
let delivered = Delivered { how, ..Delivered::NOT_PROBED };
let got = classify(compared(0, 0, true), delivered, Postcondition::Kept);
assert_ne!(got.reason, "value_kept", "for {how:?}");
}
}
#[test]
fn a_command_with_no_read_back_is_unaffected() {
for observation in [
Observation::ReportingDisabled,
Observation::ReadFailed,
Observation::NoBaseline,
compared(0, 0, false),
compared(0, 0, true),
compared(2, 0, false),
] {
assert_eq!(
classify(observation, Delivered::NOT_PROBED, Postcondition::NotRead),
plain(observation),
"for {observation:?}"
);
}
}
#[test]
fn a_value_this_action_destroyed_is_not_reported_as_a_plain_delta() {
let lost = Observation::Compared {
document_changed: false,
identity_known: true,
edits: 2,
moved: 0,
focus_moved: true,
values_lost: 1,
};
let got = plain(lost);
assert_eq!(got.verdict, Verdict::Changed, "the page did move: {got:?}");
assert_eq!(got.reason, "values_lost");
let hint = hint_for(got).expect("a values_lost hint");
assert!(hint.contains("values_lost"), "the hint names the field to read: {hint}");
assert!(
hint.contains("cleared itself"),
"and states the ambiguity rather than declaring a failure: {hint}"
);
}
#[test]
fn a_lost_value_does_not_outrank_the_document_it_was_measured_in() {
for (document_changed, identity_known, reason) in
[(true, true, "document_replaced"), (false, false, "identity_unreadable")]
{
let got = plain(Observation::Compared {
document_changed,
identity_known,
edits: 2,
moved: 0,
focus_moved: false,
values_lost: 3,
});
assert_eq!(got.reason, reason);
}
}
#[test]
fn an_emptied_field_and_a_rewritten_one_do_not_share_a_reason() {
let emptied = classify(compared(0, 0, true), Delivered::NOT_PROBED, Postcondition::Discarded);
let rewritten =
classify(compared(0, 0, true), Delivered::NOT_PROBED, Postcondition::Rewritten);
assert_eq!(emptied.verdict, Verdict::NotKept);
assert_eq!(rewritten.verdict, Verdict::NotKept);
assert_eq!(emptied.reason, "value_reverted");
assert_eq!(rewritten.reason, "value_rewritten");
assert_ne!(hint_for(emptied), hint_for(rewritten), "two recoveries, two hints");
}
#[test]
fn the_not_kept_hints_forbid_the_refill_and_name_the_field() {
for postcondition in [Postcondition::Discarded, Postcondition::Rewritten] {
let assessment =
classify(compared(0, 0, true), Delivered::NOT_PROBED, postcondition);
let hint = hint_for(assessment).expect("a not_kept hint");
assert!(
hint.contains("value.actual"),
"the hint must name what to read: {hint}"
);
assert!(
hint.contains("Do not fill it again"),
"the reflex here is a second fill, and it has to be forbidden in words: {hint}"
);
}
}
#[test]
fn a_refusal_to_dispatch_is_reported_whatever_the_page_read_did() {
let no_aim = Delivered {
how: Delivery::NotSettled,
modal_receiver: false,
observed_after_ms: None,
};
for observation in [
Observation::ReportingDisabled,
Observation::ReadFailed,
Observation::NoBaseline,
compared(0, 0, false),
] {
let got = mouse(observation, no_aim);
assert_eq!(got.verdict, Verdict::Unknown, "for {observation:?}");
assert_eq!(got.reason, "scroll_not_settled");
}
}
#[test]
fn a_focus_move_alone_is_still_something_we_saw() {
let got = plain(compared(0, 0, true));
assert_eq!(got.verdict, Verdict::Changed);
assert_eq!(got.reason, "focus_only");
let js = Delivered { how: Delivery::JsDispatch, ..hit(60) };
assert_eq!(mouse(compared(0, 0, true), js).reason, "focus_only");
}
#[test]
fn a_proven_hit_that_only_moved_focus_is_no_effect() {
let got = mouse(compared(0, 0, true), hit(60));
assert_eq!(got.verdict, Verdict::NoEffect);
assert_eq!(got.reason, "delivered_no_change");
assert_eq!(mouse(compared(2, 0, true), hit(60)).reason, "tree_delta");
assert_eq!(mouse(compared(0, 1, true), hit(60)).reason, "nodes_moved");
}
#[test]
fn no_effect_is_refused_without_proof_of_delivery() {
for how in [Delivery::JsDispatch, Delivery::NotProbed] {
let delivered =
Delivered { how, modal_receiver: false, observed_after_ms: Some(120) };
let got = mouse(compared(0, 0, false), delivered);
assert_eq!(got.verdict, Verdict::Unchanged, "for {how:?}");
assert_eq!(got.reason, "identical_tree");
}
for observation in [
Observation::ReportingDisabled,
Observation::ReadFailed,
Observation::NoBaseline,
compared(1, 0, false),
] {
assert_ne!(
mouse(observation, hit(120)).verdict,
Verdict::NoEffect,
"for {observation:?}"
);
}
}
#[test]
fn no_effect_needs_the_window_it_was_measured_over() {
let got = mouse(compared(0, 0, false), hit(140));
assert_eq!(got.verdict, Verdict::NoEffect);
assert_eq!(got.reason, "delivered_no_change");
let unmeasured = Delivered { observed_after_ms: None, ..hit(0) };
assert_eq!(mouse(compared(0, 0, false), unmeasured).verdict, Verdict::Unchanged);
}
#[test]
fn each_uncertain_verdict_carries_a_way_forward() {
for observation in [Observation::ReadFailed, Observation::NoBaseline, compared(0, 0, false)] {
assert!(hint_for(plain(observation)).is_some(), "no hint for {observation:?}");
}
for delivered in [
intercepted(false),
intercepted(true),
hit(60),
Delivered { how: Delivery::NotSettled, ..Delivered::NOT_PROBED },
Delivered { how: Delivery::OffTarget, ..Delivered::NOT_PROBED },
] {
let assessment = mouse(compared(0, 0, false), delivered);
assert!(hint_for(assessment).is_some(), "no hint for {delivered:?}");
}
}
#[test]
fn the_no_effect_hint_names_its_blind_spots() {
let hint = hint_for(mouse(compared(0, 0, false), hit(60))).expect("no_effect hint");
for blind_spot in ["canvas", "CSS-only", "after the window"] {
assert!(hint.contains(blind_spot), "hint omits {blind_spot}: {hint}");
}
assert!(hint.contains("observed_after_ms"), "hint must scope itself to the window");
}
}