use std::fmt;
use lgwks_std::similarity::{
BoundingBox, EditDistance, Geometry, Jaccard, PathSimilarity, Similarity, Weighted,
WeightedError,
};
pub const MAX_TEXT_CHARS: usize = 512;
pub const FINGERPRINT_THRESHOLD: f64 = 0.75;
pub const FINGERPRINT_MARGIN: f64 = 0.1;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum RecognitionError {
Weights(WeightedError),
InvalidMargin {
margin: f64,
},
}
impl fmt::Display for RecognitionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::Weights(ref cause) => write!(formatter, "recognition vector rejected: {cause}"),
Self::InvalidMargin { margin } => write!(
formatter,
"recognition margin {margin} must be finite and within [0, 1]"
),
}
}
}
impl std::error::Error for RecognitionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match *self {
Self::Weights(ref cause) => Some(cause),
Self::InvalidMargin { .. } => None,
}
}
}
impl From<WeightedError> for RecognitionError {
fn from(cause: WeightedError) -> Self {
Self::Weights(cause)
}
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct ElementFacts {
tag: String,
identity: Vec<String>,
path: String,
text: String,
bounds: [f64; 4],
frames: Vec<usize>,
anchors: Vec<(Anchor, String)>,
}
impl ElementFacts {
#[must_use]
pub fn new(
tag: impl Into<String>,
path: impl Into<String>,
text: impl Into<String>,
bounds: [f64; 4],
) -> Self {
Self {
tag: tag.into(),
identity: Vec::new(),
path: path.into(),
text: text.into(),
bounds,
frames: Vec::new(),
anchors: Vec::new(),
}
}
#[must_use]
pub fn with_identity(mut self, identity: Vec<String>) -> Self {
self.identity = identity;
self
}
#[must_use]
pub fn with_anchor(mut self, kind: Anchor, value: impl Into<String>) -> Self {
self.anchors.push((kind, value.into()));
self
}
#[must_use]
pub fn with_frames(mut self, frames: Vec<usize>) -> Self {
self.frames = frames;
self
}
#[must_use]
pub fn tag(&self) -> &str {
&self.tag
}
#[must_use]
pub fn identity(&self) -> &[String] {
&self.identity
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub const fn bounds(&self) -> [f64; 4] {
self.bounds
}
#[must_use]
pub fn frames(&self) -> &[usize] {
&self.frames
}
#[must_use]
pub fn anchor(&self, kind: Anchor) -> Option<&str> {
self.anchors
.iter()
.find(|entry| entry.0 == kind && !entry.1.is_empty())
.map(|entry| entry.1.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Anchor {
Id,
TestId,
Role,
Text,
ClassPath,
}
impl Anchor {
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Id => 0,
Self::TestId => 1,
Self::Role => 2,
Self::Text => 3,
Self::ClassPath => 4,
}
}
}
impl PartialOrd for Anchor {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Anchor {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum LadderError {
Empty,
}
impl fmt::Display for LadderError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::Empty => formatter.write_str("a locator ladder needs at least one anchor"),
}
}
}
impl std::error::Error for LadderError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ladder {
anchors: Vec<Anchor>,
}
impl Ladder {
pub fn new(anchors: impl IntoIterator<Item = Anchor>) -> Result<Self, LadderError> {
let mut sorted: Vec<Anchor> = anchors.into_iter().collect();
if sorted.is_empty() {
let refusal = Err(LadderError::Empty);
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
sorted.sort_unstable_by_key(|anchor| anchor.rank());
sorted.dedup();
Ok(Self { anchors: sorted })
}
#[must_use]
pub fn anchors(&self) -> &[Anchor] {
&self.anchors
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct IdentityComponent {
metric: Jaccard<String>,
}
impl IdentityComponent {
#[must_use]
pub const fn new() -> Self {
Self {
metric: Jaccard::new(),
}
}
}
impl Default for IdentityComponent {
fn default() -> Self {
Self::new()
}
}
impl Similarity for IdentityComponent {
type Value = ElementFacts;
fn score(&self, left: &Self::Value, right: &Self::Value) -> f64 {
if left.identity.is_empty() || right.identity.is_empty() {
return 0.0;
}
self.metric.score(&left.identity, &right.identity)
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct PathComponent {
metric: PathSimilarity,
}
impl PathComponent {
#[must_use]
pub const fn new() -> Self {
Self {
metric: PathSimilarity::new(),
}
}
}
impl Default for PathComponent {
fn default() -> Self {
Self::new()
}
}
impl Similarity for PathComponent {
type Value = ElementFacts;
fn score(&self, left: &Self::Value, right: &Self::Value) -> f64 {
if left.path.is_empty() || right.path.is_empty() {
return 0.0;
}
self.metric.score(&left.path, &right.path)
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct TextComponent {
metric: EditDistance,
}
impl TextComponent {
#[must_use]
pub const fn new(maximum_length: usize) -> Self {
Self {
metric: EditDistance::new(maximum_length),
}
}
}
impl Similarity for TextComponent {
type Value = ElementFacts;
fn score(&self, left: &Self::Value, right: &Self::Value) -> f64 {
if left.text.is_empty() || right.text.is_empty() {
return 0.0;
}
self.metric.score(&left.text, &right.text)
}
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct GeometryComponent {
metric: Geometry,
}
impl GeometryComponent {
#[must_use]
pub const fn new(maximum_distance: f64) -> Self {
Self {
metric: Geometry::new(maximum_distance),
}
}
}
impl Similarity for GeometryComponent {
type Value = ElementFacts;
fn score(&self, left: &Self::Value, right: &Self::Value) -> f64 {
let left_bounds = BoundingBox::from(left.bounds);
let right_bounds = BoundingBox::from(right.bounds);
self.metric.score(&left_bounds, &right_bounds)
}
}
#[derive(Debug)]
pub struct RecognitionVector {
scorer: Weighted<ElementFacts>,
margin: f64,
}
impl RecognitionVector {
pub fn new(
components: Vec<(f64, Box<dyn Similarity<Value = ElementFacts>>)>,
threshold: f64,
margin: f64,
) -> Result<Self, RecognitionError> {
if !margin.is_finite() || !(0.0..=1.0).contains(&margin) {
let refusal = Err(RecognitionError::InvalidMargin { margin });
lgwks_std::trace::debug!(error = ?refusal.as_ref().err(), "new: returning an error to the caller");
return refusal;
}
Ok(Self {
scorer: Weighted::new(components, threshold)?,
margin,
})
}
pub fn fingerprint() -> Result<Self, RecognitionError> {
let components: Vec<(f64, Box<dyn Similarity<Value = ElementFacts>>)> = vec![
(0.5, Box::new(IdentityComponent::new())),
(0.25, Box::new(PathComponent::new())),
(0.125, Box::new(TextComponent::new(MAX_TEXT_CHARS))),
(0.125, Box::new(GeometryComponent::new(1.0))),
];
Self::new(components, FINGERPRINT_THRESHOLD, FINGERPRINT_MARGIN)
}
#[must_use]
pub fn threshold(&self) -> f64 {
self.scorer.threshold()
}
#[must_use]
pub const fn margin(&self) -> f64 {
self.margin
}
#[must_use]
pub fn recognize(&self, target: &ElementFacts, candidates: &[ElementFacts]) -> Recognition {
let mut best: Option<(usize, f64)> = None;
let mut next: Option<(usize, f64)> = None;
for (index, candidate) in candidates.iter().enumerate() {
if !is_eligible(target, candidate) {
continue;
}
let score = self.scorer.score(target, candidate);
match best {
Some((_, best_score)) if score > best_score => {
next = best;
best = Some((index, score));
}
Some(_) => {
if next.is_none_or(|(_, next_score)| score > next_score) {
next = Some((index, score));
}
}
None => best = Some((index, score)),
}
}
let Some((index, score)) = best else {
return Recognition::Absent { best_score: 0.0 };
};
if score < self.scorer.threshold() {
return Recognition::Absent { best_score: score };
}
let (runner_up, lead) = match next {
Some((runner_up_index, runner_up_score)) => {
(Some(runner_up_index), score - runner_up_score)
}
None => (None, score),
};
if lead < self.margin {
return Recognition::Ambiguous {
best: index,
runner_up,
score,
lead,
};
}
Recognition::Resolved { index, score, lead }
}
#[must_use]
pub fn recognize_with_ladder(
&self,
ladder: &Ladder,
target: &ElementFacts,
candidates: &[ElementFacts],
) -> Recognition {
let mut strongest_absent = Recognition::Absent { best_score: 0.0 };
for kind in ladder.anchors() {
let Some(wanted) = target.anchor(*kind) else {
continue;
};
let hits: Vec<usize> = candidates
.iter()
.enumerate()
.filter(|entry| {
is_eligible(target, entry.1) && entry.1.anchor(*kind) == Some(wanted)
})
.map(|(index, _)| index)
.collect();
match hits.len() {
0 => {
let near_miss = candidates
.iter()
.filter(|candidate| {
is_eligible(target, candidate) && candidate.anchor(*kind).is_some()
})
.map(|candidate| self.scorer.score(target, candidate))
.fold(0.0_f64, f64::max);
if let Recognition::Absent { best_score: prior } = strongest_absent
&& near_miss > prior
{
strongest_absent = Recognition::Absent {
best_score: near_miss,
};
}
}
1 => {
let index = hits[0];
let score = self.scorer.score(target, &candidates[index]);
return Recognition::Resolved {
index,
score,
lead: score,
};
}
_ => {
let mut scored: Vec<(usize, f64)> = hits
.iter()
.map(|&index| (index, self.scorer.score(target, &candidates[index])))
.collect();
scored.sort_by(|left, right| {
right.1.total_cmp(&left.1).then(left.0.cmp(&right.0))
});
let (best, score) = scored[0];
let (runner_up, runner_up_score) = scored[1];
return Recognition::Ambiguous {
best,
runner_up: Some(runner_up),
score,
lead: score - runner_up_score,
};
}
}
}
strongest_absent
}
}
fn is_eligible(target: &ElementFacts, candidate: &ElementFacts) -> bool {
candidate.frames == target.frames && tags_compatible(target, candidate)
}
fn tags_compatible(target: &ElementFacts, candidate: &ElementFacts) -> bool {
target.tag.is_empty() || candidate.tag.is_empty() || target.tag == candidate.tag
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Recognition {
Resolved {
index: usize,
score: f64,
lead: f64,
},
Ambiguous {
best: usize,
runner_up: Option<usize>,
score: f64,
lead: f64,
},
Absent {
best_score: f64,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_close(left: f64, right: f64) {
assert!(
(left - right).abs() < 1e-9,
"expected {right}, observed {left}"
);
}
fn candidate(identity: &str, text: &str) -> ElementFacts {
ElementFacts::new(
"button",
"html > body > form > button",
text,
[0.5, 0.5, 0.1, 0.05],
)
.with_identity(vec![String::from(identity)])
}
fn vector() -> Result<RecognitionVector, RecognitionError> {
RecognitionVector::fingerprint()
}
#[test]
fn identical_facts_resolve_with_a_full_score() -> Result<(), RecognitionError> {
let target = candidate("data-testid=submit", "Submit");
assert_eq!(
vector()?.recognize(&target, std::slice::from_ref(&target)),
Recognition::Resolved {
index: 0,
score: 1.0,
lead: 1.0,
}
);
Ok(())
}
#[test]
fn an_empty_candidate_set_is_absent_not_a_panic() -> Result<(), RecognitionError> {
let target = candidate("data-testid=submit", "Submit");
assert_eq!(
vector()?.recognize(&target, &[]),
Recognition::Absent { best_score: 0.0 }
);
Ok(())
}
#[test]
fn two_indistinguishable_candidates_are_ambiguous() -> Result<(), RecognitionError> {
let target = candidate("data-testid=submit", "Submit");
let twin = candidate("data-testid=submit", "Submit");
assert_eq!(
vector()?.recognize(&target, &[twin.clone(), twin]),
Recognition::Ambiguous {
best: 0,
runner_up: Some(1),
score: 1.0,
lead: 0.0,
},
"the lowest index must win a tie, and the pair must not resolve"
);
Ok(())
}
fn bare(tag: &str, path: &str) -> ElementFacts {
ElementFacts::new(tag, path, "", [0.5, 0.5, 0.1, 0.05])
}
fn icon(identifier: &str) -> ElementFacts {
bare("button", "html > body > nav > button").with_identity(vec![String::from(identifier)])
}
#[test]
fn absent_identity_is_not_evidence_that_an_unrelated_element_matches()
-> Result<(), RecognitionError> {
let target = ElementFacts::new("button", "button", "", [0.5, 0.5, 0.1, 0.05]);
let unrelated = ElementFacts::new("img", "img", "", [0.5, 0.5, 0.1, 0.05]);
let result = vector()?.recognize(&target, &[unrelated]);
assert!(matches!(result, Recognition::Absent { .. }), "{result:?}");
Ok(())
}
#[test]
fn the_metric_convention_is_intact_where_it_belongs() {
let no_attributes: [String; 0] = [];
assert_close(
Jaccard::<String>::new().score(&no_attributes, &no_attributes),
1.0,
);
assert_close(EditDistance::new(8).score("", ""), 1.0);
assert_close(PathSimilarity::new().score("", ""), 1.0);
let blank = bare("button", "");
assert_close(IdentityComponent::new().score(&blank, &blank), 0.0);
assert_close(TextComponent::new(8).score(&blank, &blank), 0.0);
assert_close(PathComponent::new().score(&blank, &blank), 0.0);
}
#[test]
fn an_element_with_nothing_to_identify_it_by_cannot_resolve() -> Result<(), RecognitionError> {
let recognition = vector()?.recognize(
&bare("button", "html > body > form > button"),
&[bare("button", "html > body > form > button")],
);
assert!(
matches!(
recognition,
Recognition::Absent { best_score } if best_score < FINGERPRINT_THRESHOLD
),
"two elements with no identifying evidence must not match, got {recognition:?}"
);
Ok(())
}
#[test]
fn an_icon_only_control_resolves_on_its_identifier() -> Result<(), RecognitionError> {
let target = icon("data-testid=menu");
let recognition = vector()?.recognize(&target, std::slice::from_ref(&target));
assert!(
matches!(
recognition,
Recognition::Resolved { index: 0, score, lead }
if (score - 0.875).abs() < 1e-9 && lead >= FINGERPRINT_MARGIN
),
"a matching identifier must still resolve an icon-only control, got {recognition:?}"
);
Ok(())
}
#[test]
fn an_icon_only_control_does_not_match_on_layout() -> Result<(), RecognitionError> {
let target = icon("data-testid=menu");
let other = icon("data-testid=close");
let recognition = vector()?.recognize(&target, &[other]);
assert!(
matches!(
recognition,
Recognition::Absent { best_score } if best_score < FINGERPRINT_THRESHOLD
),
"a quarter of the vector must not resolve, got {recognition:?}"
);
Ok(())
}
#[test]
fn an_unrelated_tag_is_excluded_however_well_the_rest_agrees() -> Result<(), RecognitionError> {
let facts = |tag: &str| {
ElementFacts::new(
tag,
"html > body > form > button",
"Submit",
[0.5, 0.5, 0.1, 0.05],
)
.with_identity(vec![String::from("data-testid=submit")])
};
let target = facts("button");
let impostor = facts("img");
assert_eq!(
vector()?.recognize(&target, &[impostor]),
Recognition::Absent { best_score: 0.0 },
"a different element kind is not a candidate"
);
let twin = facts("button");
assert!(
matches!(
vector()?.recognize(&target, &[twin]),
Recognition::Resolved { score, .. } if (score - 1.0).abs() < 1e-9
),
"the identical element on the same facts still resolves"
);
Ok(())
}
#[test]
fn an_unreported_tag_excludes_nothing() -> Result<(), RecognitionError> {
let target = ElementFacts::new(
"button",
"html > body > form > button",
"Submit",
[0.5, 0.5, 0.1, 0.05],
)
.with_identity(vec![String::from("data-testid=submit")]);
let unlabelled = ElementFacts::new(
"",
"html > body > form > button",
"Submit",
[0.5, 0.5, 0.1, 0.05],
)
.with_identity(vec![String::from("data-testid=submit")]);
let recognition = vector()?.recognize(&target, &[unlabelled]);
assert!(
matches!(recognition, Recognition::Resolved { index: 0, .. }),
"an unreported tag must not exclude an otherwise matching element, got {recognition:?}"
);
Ok(())
}
#[test]
fn structurally_distinct_candidates_are_measured_and_rejected() -> Result<(), RecognitionError>
{
let target = ElementFacts::new(
"button",
"html > body > form > button",
"Submit order",
[0.5, 0.5, 0.1, 0.05],
)
.with_identity(vec![String::from("data-testid=submit")]);
let elsewhere = ElementFacts::new(
"button",
"html > body > article > aside > button",
"Terms and conditions",
[900.0, 900.0, 0.1, 0.05],
)
.with_identity(vec![String::from("data-testid=legal")]);
let recognition = vector()?.recognize(&target, &[elsewhere]);
assert!(
matches!(
recognition,
Recognition::Absent { best_score }
if best_score < FINGERPRINT_THRESHOLD && best_score > 0.0
),
"a scored near-miss is Absent at its measured score, got {recognition:?}"
);
Ok(())
}
#[test]
fn two_indistinguishable_icon_only_candidates_are_ambiguous() -> Result<(), RecognitionError> {
let target = icon("data-testid=menu");
let recognition = vector()?.recognize(&target, &[target.clone(), target.clone()]);
assert!(
matches!(
&recognition,
Recognition::Ambiguous { best: 0, runner_up: Some(1), score, lead }
if (score - 0.875).abs() < 1e-9 && lead.abs() < 1e-9
),
"two indistinguishable icon-only controls are a tie, got {recognition:?}"
);
Ok(())
}
#[test]
fn a_clear_winner_resolves() -> Result<(), RecognitionError> {
let target = candidate("data-testid=submit", "Submit order");
let candidates = [candidate("data-testid=cancel", "Cancel"), target.clone()];
let recognition = vector()?.recognize(&target, &candidates);
assert!(
matches!(
recognition,
Recognition::Resolved { index: 1, score, lead }
if (score - 1.0).abs() < 1e-9 && lead >= FINGERPRINT_MARGIN
),
"the second candidate must win outright with a lead over the margin, got {recognition:?}"
);
Ok(())
}
#[test]
fn a_candidate_in_another_document_is_excluded_before_scoring() -> Result<(), RecognitionError>
{
let target = candidate("data-testid=submit", "Submit").with_frames(vec![0]);
let look_alike = candidate("data-testid=submit", "Submit").with_frames(vec![3]);
assert_eq!(
vector()?.recognize(&target, &[look_alike]),
Recognition::Absent { best_score: 0.0 },
"a perfect score across a frame boundary must not be reachable"
);
Ok(())
}
#[test]
fn a_weak_candidate_is_absent_below_the_threshold() -> Result<(), RecognitionError> {
let target = candidate("data-testid=submit", "Submit order");
let unrelated = ElementFacts::new(
"section",
"html > body > article",
"Terms and conditions",
[0.0, 0.0, 0.1, 0.1],
)
.with_identity(vec![String::from("data-testid=legal")]);
let recognition = vector()?.recognize(&target, &[unrelated]);
assert!(
matches!(
recognition,
Recognition::Absent { best_score } if best_score < FINGERPRINT_THRESHOLD
),
"an unrelated element must be Absent below the threshold, got {recognition:?}"
);
Ok(())
}
#[test]
fn an_empty_component_set_is_refused() {
let components: Vec<(f64, Box<dyn Similarity<Value = ElementFacts>>)> = Vec::new();
assert_eq!(
RecognitionVector::new(components, 0.5, 0.1).err(),
Some(RecognitionError::Weights(WeightedError::Empty))
);
}
#[test]
fn weights_above_one_are_refused() {
let components: Vec<(f64, Box<dyn Similarity<Value = ElementFacts>>)> = vec![
(0.75, Box::new(IdentityComponent::new())),
(0.75, Box::new(PathComponent::new())),
];
assert_eq!(
RecognitionVector::new(components, 0.5, 0.1).err(),
Some(RecognitionError::Weights(
WeightedError::WeightSumExceedsOne
))
);
}
#[test]
fn a_margin_outside_the_unit_interval_is_refused() {
let components: Vec<(f64, Box<dyn Similarity<Value = ElementFacts>>)> =
vec![(0.5, Box::new(IdentityComponent::new()))];
assert_eq!(
RecognitionVector::new(components, 0.5, 1.5).err(),
Some(RecognitionError::InvalidMargin { margin: 1.5 })
);
}
#[test]
fn the_shipped_vector_carries_its_declared_rules() -> Result<(), RecognitionError> {
let vector = vector()?;
assert_close(vector.threshold(), FINGERPRINT_THRESHOLD);
assert_close(vector.margin(), FINGERPRINT_MARGIN);
assert_close(0.5 + 0.25 + 0.125 + 0.125, 1.0);
Ok(())
}
fn anchored(tag: &str, kind: Anchor, value: &str, text: &str) -> ElementFacts {
ElementFacts::new(
tag,
"html > body > form > button",
text,
[0.5, 0.5, 0.1, 0.05],
)
.with_anchor(kind, value)
.with_identity(vec![format!("{kind:?}={value}")])
}
#[test]
fn an_empty_ladder_is_an_error() {
assert_eq!(Ladder::new([]), Err(LadderError::Empty));
}
#[test]
fn a_ladder_is_sorted_and_deduplicated_strongest_first() -> Result<(), LadderError> {
let ladder = Ladder::new([
Anchor::ClassPath,
Anchor::Id,
Anchor::Text,
Anchor::Id,
Anchor::TestId,
])?;
assert_eq!(
ladder.anchors(),
&[Anchor::Id, Anchor::TestId, Anchor::Text, Anchor::ClassPath],
"input order must not decide rank, and a repeated rung is one rung"
);
Ok(())
}
#[test]
fn an_empty_anchor_value_is_not_offered() {
let facts = anchored("button", Anchor::Id, "", "Submit");
assert_eq!(facts.anchor(Anchor::Id), None);
assert_eq!(facts.anchor(Anchor::TestId), None);
}
#[test]
fn a_unique_strong_anchor_resolves_at_the_first_rung() -> Result<(), Box<dyn std::error::Error>>
{
let target = anchored("button", Anchor::Id, "submit", "Submit");
let other = anchored("button", Anchor::Id, "cancel", "Cancel");
let ladder = Ladder::new([Anchor::Id, Anchor::Text])?;
let recognition =
vector()?.recognize_with_ladder(&ladder, &target, &[other, target.clone()]);
assert!(
matches!(recognition, Recognition::Resolved { index: 1, .. }),
"the sole id match must resolve, got {recognition:?}"
);
Ok(())
}
#[test]
fn ambiguous_at_a_strong_anchor_is_not_retried_at_a_weak_one()
-> Result<(), Box<dyn std::error::Error>> {
let target = anchored("button", Anchor::Id, "submit", "Submit");
let twin = anchored("button", Anchor::Id, "submit", "Delete");
let ladder = Ladder::new([Anchor::Id, Anchor::Text])?;
let recognition =
vector()?.recognize_with_ladder(&ladder, &target, &[twin, target.clone()]);
assert!(
matches!(recognition, Recognition::Ambiguous { .. }),
"a strong-rung ambiguity must stand, got {recognition:?}"
);
Ok(())
}
#[test]
fn the_ladder_falls_through_when_the_strong_anchor_is_absent()
-> Result<(), Box<dyn std::error::Error>> {
let target = anchored("button", Anchor::TestId, "checkout", "Submit");
let other = anchored("button", Anchor::TestId, "cancel", "Cancel");
let ladder = Ladder::new([Anchor::Id, Anchor::TestId])?;
let recognition =
vector()?.recognize_with_ladder(&ladder, &target, &[other, target.clone()]);
assert!(
matches!(recognition, Recognition::Resolved { index: 1, .. }),
"the fall-down must reach the test-id rung, got {recognition:?}"
);
Ok(())
}
#[test]
fn every_rung_absent_reports_the_strongest_absent() -> Result<(), Box<dyn std::error::Error>> {
let target = ElementFacts::new(
"button",
"html > body > form > button",
"Submit",
[0.5, 0.5, 0.1, 0.05],
)
.with_anchor(Anchor::Id, "submit")
.with_anchor(Anchor::ClassPath, "html > body > form > button");
let unrelated = ElementFacts::new(
"button",
"html > body > footer > button",
"Cancel",
[0.1, 0.9, 0.1, 0.05],
)
.with_anchor(Anchor::Id, "cancel")
.with_anchor(Anchor::ClassPath, "html > body > footer > button");
let ladder = Ladder::new([Anchor::Id, Anchor::ClassPath])?;
let recognition = vector()?.recognize_with_ladder(&ladder, &target, &[unrelated]);
assert!(
matches!(
recognition,
Recognition::Absent { best_score }
if (0.0..FINGERPRINT_THRESHOLD).contains(&best_score)
),
"absent must report a sub-threshold score, got {recognition:?}"
);
Ok(())
}
#[test]
fn a_candidate_that_offers_no_matching_anchor_is_never_scored()
-> Result<(), Box<dyn std::error::Error>> {
let target = anchored("button", Anchor::Id, "submit", "Submit");
let blank = bare("button", "html > body > form > button");
let ladder = Ladder::new([Anchor::Id])?;
assert_eq!(
vector()?.recognize_with_ladder(&ladder, &target, &[blank]),
Recognition::Absent { best_score: 0.0 },
"an id rung with nobody in it is a fall-through to a finished ladder"
);
Ok(())
}
}