1use std::fmt;
26use std::time::{Duration, Instant};
27
28use serde::{Deserialize, Serialize};
29use serde_json::{Value, json};
30
31use crate::VictauriClient;
32use crate::error::TestError;
33
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
38pub struct Bounds {
39 pub x: f64,
41 pub y: f64,
43 pub width: f64,
45 pub height: f64,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct LocatorMatch {
52 pub ref_id: String,
54 pub tag: String,
56 pub role: Option<String>,
58 pub name: Option<String>,
60 pub text: Option<String>,
62 pub visible: bool,
64 pub enabled: bool,
66 pub value: Option<String>,
68 pub bounds: Option<Bounds>,
70}
71
72#[derive(Debug, Clone)]
76enum Strategy {
77 Role(String),
78 Text(String),
79 TextExact(String),
80 TestId(String),
81 Css(String),
82 Label(String),
83 Placeholder(String),
84 AltText(String),
85 Title(String),
86}
87
88#[derive(Debug, Clone)]
90enum Filter {
91 Text(String),
92 TextExact(String),
93 Role(String),
94 Name(String),
95 Tag(String),
96 HasAttribute(String, Option<String>),
97}
98
99#[derive(Debug, Clone)]
101enum Pick {
102 First,
103 Nth(usize),
104 Last,
105}
106
107#[derive(Debug, Clone)]
117pub struct Locator {
118 strategy: Strategy,
119 filters: Vec<Filter>,
120 pick: Pick,
121}
122
123const _: () = {
125 fn _assert_send_sync<T: Send + Sync>() {}
126 fn _check() {
127 _assert_send_sync::<Locator>();
128 }
129};
130
131impl Locator {
132 #[must_use]
136 pub fn role(role: &str) -> Self {
137 Self {
138 strategy: Strategy::Role(role.to_string()),
139 filters: Vec::new(),
140 pick: Pick::First,
141 }
142 }
143
144 #[must_use]
146 pub fn text(text: &str) -> Self {
147 Self {
148 strategy: Strategy::Text(text.to_string()),
149 filters: Vec::new(),
150 pick: Pick::First,
151 }
152 }
153
154 #[must_use]
156 pub fn text_exact(text: &str) -> Self {
157 Self {
158 strategy: Strategy::TextExact(text.to_string()),
159 filters: Vec::new(),
160 pick: Pick::First,
161 }
162 }
163
164 #[must_use]
166 pub fn test_id(id: &str) -> Self {
167 Self {
168 strategy: Strategy::TestId(id.to_string()),
169 filters: Vec::new(),
170 pick: Pick::First,
171 }
172 }
173
174 #[must_use]
176 pub fn css(selector: &str) -> Self {
177 Self {
178 strategy: Strategy::Css(selector.to_string()),
179 filters: Vec::new(),
180 pick: Pick::First,
181 }
182 }
183
184 #[must_use]
186 pub fn label(text: &str) -> Self {
187 Self {
188 strategy: Strategy::Label(text.to_string()),
189 filters: Vec::new(),
190 pick: Pick::First,
191 }
192 }
193
194 #[must_use]
196 pub fn placeholder(text: &str) -> Self {
197 Self {
198 strategy: Strategy::Placeholder(text.to_string()),
199 filters: Vec::new(),
200 pick: Pick::First,
201 }
202 }
203
204 #[must_use]
206 pub fn alt_text(alt: &str) -> Self {
207 Self {
208 strategy: Strategy::AltText(alt.to_string()),
209 filters: Vec::new(),
210 pick: Pick::First,
211 }
212 }
213
214 #[must_use]
216 pub fn title(title: &str) -> Self {
217 Self {
218 strategy: Strategy::Title(title.to_string()),
219 filters: Vec::new(),
220 pick: Pick::First,
221 }
222 }
223
224 #[must_use]
228 pub fn and_text(mut self, text: &str) -> Self {
229 self.filters.push(Filter::Text(text.to_string()));
230 self
231 }
232
233 #[must_use]
235 pub fn and_text_exact(mut self, text: &str) -> Self {
236 self.filters.push(Filter::TextExact(text.to_string()));
237 self
238 }
239
240 #[must_use]
242 pub fn and_role(mut self, role: &str) -> Self {
243 self.filters.push(Filter::Role(role.to_string()));
244 self
245 }
246
247 #[must_use]
249 pub fn name(mut self, name: &str) -> Self {
250 self.filters.push(Filter::Name(name.to_string()));
251 self
252 }
253
254 #[must_use]
256 pub fn and_tag(mut self, tag: &str) -> Self {
257 self.filters.push(Filter::Tag(tag.to_string()));
258 self
259 }
260
261 #[must_use]
263 pub fn and_has_attribute(mut self, attr_name: &str, attr_value: Option<&str>) -> Self {
264 self.filters.push(Filter::HasAttribute(
265 attr_name.to_string(),
266 attr_value.map(String::from),
267 ));
268 self
269 }
270
271 #[must_use]
273 pub fn nth(mut self, n: usize) -> Self {
274 self.pick = Pick::Nth(n);
275 self
276 }
277
278 #[must_use]
280 pub fn first(mut self) -> Self {
281 self.pick = Pick::First;
282 self
283 }
284
285 #[must_use]
287 pub fn last(mut self) -> Self {
288 self.pick = Pick::Last;
289 self
290 }
291
292 pub async fn click(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
300 let el = self.resolve_one(client).await?;
301 client.click(&el.ref_id).await
302 }
303
304 pub async fn double_click(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
310 let el = self.resolve_one(client).await?;
311 client.double_click(&el.ref_id).await
312 }
313
314 pub async fn fill(&self, client: &mut VictauriClient, value: &str) -> Result<Value, TestError> {
320 let el = self.resolve_one(client).await?;
321 client.fill(&el.ref_id, value).await
322 }
323
324 pub async fn type_text(
330 &self,
331 client: &mut VictauriClient,
332 text: &str,
333 ) -> Result<Value, TestError> {
334 let el = self.resolve_one(client).await?;
335 client.type_text(&el.ref_id, text).await
336 }
337
338 pub async fn press_key(
344 &self,
345 client: &mut VictauriClient,
346 key: &str,
347 ) -> Result<Value, TestError> {
348 let _el = self.resolve_one(client).await?;
349 client.press_key(key).await
350 }
351
352 pub async fn hover(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
358 let el = self.resolve_one(client).await?;
359 client.hover(&el.ref_id).await
360 }
361
362 pub async fn focus(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
368 let el = self.resolve_one(client).await?;
369 client.focus(&el.ref_id).await
370 }
371
372 pub async fn blur(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
378 let _el = self.resolve_one(client).await?;
379 client.eval_js("document.activeElement?.blur()").await
380 }
381
382 pub async fn scroll_into_view(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
388 let el = self.resolve_one(client).await?;
389 client.scroll_to(&el.ref_id).await
390 }
391
392 pub async fn select_option(
398 &self,
399 client: &mut VictauriClient,
400 values: &[&str],
401 ) -> Result<Value, TestError> {
402 let el = self.resolve_one(client).await?;
403 client.select_option(&el.ref_id, values).await
404 }
405
406 pub async fn check(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
412 let el = self.resolve_one(client).await?;
413 let code = format!(
414 "(function() {{ var el = window.__VICTAURI__?.getRef({}); \
415 if (!el) return null; \
416 if (!el.checked) {{ el.checked = true; \
417 el.dispatchEvent(new Event('change', {{bubbles:true}})); \
418 el.dispatchEvent(new Event('input', {{bubbles:true}})); }} \
419 return true; }})()",
420 serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()),
421 );
422 client.eval_js(&code).await
423 }
424
425 pub async fn uncheck(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
431 let el = self.resolve_one(client).await?;
432 let code = format!(
433 "(function() {{ var el = window.__VICTAURI__?.getRef({}); \
434 if (!el) return null; \
435 if (el.checked) {{ el.checked = false; \
436 el.dispatchEvent(new Event('change', {{bubbles:true}})); \
437 el.dispatchEvent(new Event('input', {{bubbles:true}})); }} \
438 return true; }})()",
439 serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()),
440 );
441 client.eval_js(&code).await
442 }
443
444 pub async fn text_content(&self, client: &mut VictauriClient) -> Result<String, TestError> {
452 let val = self
453 .eval_on_element(client, "return el.textContent || \"\";")
454 .await?;
455 Ok(value_to_string(&val))
456 }
457
458 pub async fn inner_text(&self, client: &mut VictauriClient) -> Result<String, TestError> {
464 let val = self
465 .eval_on_element(client, "return el.innerText || \"\";")
466 .await?;
467 Ok(value_to_string(&val))
468 }
469
470 pub async fn input_value(&self, client: &mut VictauriClient) -> Result<String, TestError> {
476 let val = self
477 .eval_on_element(client, "return el.value || \"\";")
478 .await?;
479 Ok(value_to_string(&val))
480 }
481
482 pub async fn is_visible(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
488 let el = self.resolve_one(client).await?;
489 Ok(el.visible)
490 }
491
492 pub async fn is_enabled(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
498 let el = self.resolve_one(client).await?;
499 Ok(el.enabled)
500 }
501
502 pub async fn is_checked(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
508 let val = self.eval_on_element(client, "return !!el.checked;").await?;
509 Ok(val.as_bool().unwrap_or(false))
510 }
511
512 pub async fn is_focused(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
518 let val = self
519 .eval_on_element(client, "return document.activeElement === el;")
520 .await?;
521 Ok(val.as_bool().unwrap_or(false))
522 }
523
524 pub async fn count(&self, client: &mut VictauriClient) -> Result<usize, TestError> {
530 let all = self.resolve_all(client).await?;
531 Ok(all.len())
532 }
533
534 pub async fn bounding_box(
540 &self,
541 client: &mut VictauriClient,
542 ) -> Result<Option<Bounds>, TestError> {
543 let el = self.resolve_one(client).await?;
544 Ok(el.bounds)
545 }
546
547 pub async fn get_attribute(
553 &self,
554 client: &mut VictauriClient,
555 attr_name: &str,
556 ) -> Result<Option<String>, TestError> {
557 let escaped = attr_name.replace('\\', "\\\\").replace('"', "\\\"");
558 let js_body = format!("return el.getAttribute(\"{escaped}\");");
559 let val = self.eval_on_element(client, &js_body).await?;
560 if val.is_null() {
561 Ok(None)
562 } else {
563 Ok(Some(value_to_string(&val)))
564 }
565 }
566
567 pub async fn all(&self, client: &mut VictauriClient) -> Result<Vec<LocatorMatch>, TestError> {
573 self.resolve_all(client).await
574 }
575
576 pub async fn all_text_contents(
582 &self,
583 client: &mut VictauriClient,
584 ) -> Result<Vec<String>, TestError> {
585 let elements = self.resolve_all(client).await?;
586 let mut texts = Vec::with_capacity(elements.len());
587 for el in &elements {
588 let code = format!(
589 "(function() {{ var el = window.__VICTAURI__?.getRef({}); \
590 if (!el) return \"\"; \
591 return el.textContent || \"\"; }})()",
592 serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()),
593 );
594 let val = client.eval_js(&code).await?;
595 texts.push(value_to_string(&val));
596 }
597 Ok(texts)
598 }
599
600 pub fn expect<'a>(&'a self, client: &'a mut VictauriClient) -> LocatorExpect<'a> {
604 LocatorExpect {
605 locator: self,
606 client,
607 timeout_ms: 5000,
608 poll_ms: 200,
609 negated: false,
610 }
611 }
612
613 async fn resolve_all(
616 &self,
617 client: &mut VictauriClient,
618 ) -> Result<Vec<LocatorMatch>, TestError> {
619 let query = self.build_query();
620 let result = client.find_elements(query).await?;
621 let mut elements = Self::parse_elements(&result);
622 elements = self.apply_filters(elements);
623 Ok(elements)
624 }
625
626 async fn resolve_one(&self, client: &mut VictauriClient) -> Result<LocatorMatch, TestError> {
627 let all = self.resolve_all(client).await?;
628 self.pick_one(all)
629 }
630
631 fn pick_one(&self, all: Vec<LocatorMatch>) -> Result<LocatorMatch, TestError> {
632 if all.is_empty() {
633 return Err(TestError::ElementNotFound(format!(
634 "no elements match {self}"
635 )));
636 }
637 match self.pick {
638 Pick::First => Ok(all.into_iter().next().expect("checked non-empty")),
639 Pick::Last => Ok(all.into_iter().last().expect("checked non-empty")),
640 Pick::Nth(n) => {
641 let total = all.len();
642 all.into_iter().nth(n).ok_or_else(|| {
643 TestError::ElementNotFound(format!(
644 "{self}: wanted index {n} but only {total} elements matched"
645 ))
646 })
647 }
648 }
649 }
650
651 fn build_query(&self) -> Value {
652 let mut query = json!({});
653 match &self.strategy {
654 Strategy::Role(r) => {
655 query["role"] = json!(r);
656 }
657 Strategy::Text(t) => {
658 query["text"] = json!(t);
659 }
660 Strategy::TextExact(t) => {
661 query["text"] = json!(t);
662 query["exact"] = json!(true);
663 }
664 Strategy::TestId(id) => {
665 query["test_id"] = json!(id);
666 }
667 Strategy::Css(sel) => {
668 query["css"] = json!(sel);
669 }
670 Strategy::Label(t) => {
671 query["label"] = json!(t);
672 }
673 Strategy::Placeholder(t) => {
674 query["placeholder"] = json!(t);
675 }
676 Strategy::AltText(a) => {
677 query["alt"] = json!(a);
678 }
679 Strategy::Title(t) => {
680 query["title_attr"] = json!(t);
681 }
682 }
683
684 for filter in &self.filters {
686 match filter {
687 Filter::Role(r) => {
688 query["role"] = json!(r);
689 }
690 Filter::Name(n) => {
691 query["name"] = json!(n);
692 }
693 Filter::Tag(t) => {
694 query["tag"] = json!(t);
695 }
696 Filter::Text(_) | Filter::TextExact(_) | Filter::HasAttribute(_, _) => {}
698 }
699 }
700
701 query["max_results"] = json!(50);
702 query
703 }
704
705 fn apply_filters(&self, elements: Vec<LocatorMatch>) -> Vec<LocatorMatch> {
706 let mut result = elements;
707
708 if let Strategy::TextExact(ref expected) = self.strategy {
711 result.retain(|el| {
712 el.text
713 .as_deref()
714 .is_some_and(|t| t.trim() == expected.as_str())
715 });
716 }
717
718 for filter in &self.filters {
719 match filter {
720 Filter::Text(expected) => {
721 let lower = expected.to_lowercase();
722 result.retain(|el| {
723 el.text
724 .as_deref()
725 .is_some_and(|t| t.to_lowercase().contains(&lower))
726 });
727 }
728 Filter::TextExact(expected) => {
729 result.retain(|el| {
730 el.text
731 .as_deref()
732 .is_some_and(|t| t.trim() == expected.as_str())
733 });
734 }
735 Filter::Role(expected) => {
736 result.retain(|el| el.role.as_deref().is_some_and(|r| r == expected.as_str()));
737 }
738 Filter::Name(expected) => {
739 let lower = expected.to_lowercase();
740 result.retain(|el| {
741 el.name
742 .as_deref()
743 .is_some_and(|n| n.to_lowercase().contains(&lower))
744 });
745 }
746 Filter::Tag(expected) => {
747 result.retain(|el| el.tag == *expected);
748 }
749 Filter::HasAttribute(_, _) => {}
751 }
752 }
753
754 result
755 }
756
757 fn parse_elements(result: &Value) -> Vec<LocatorMatch> {
758 let array = result
759 .as_array()
760 .or_else(|| result.get("elements").and_then(Value::as_array));
761
762 let Some(arr) = array else {
763 return Vec::new();
764 };
765
766 let mut out = Vec::with_capacity(arr.len());
767 for item in arr {
768 let Some(ref_id) = item.get("ref_id").and_then(Value::as_str) else {
769 continue;
770 };
771 let tag = item
772 .get("tag")
773 .and_then(Value::as_str)
774 .unwrap_or("unknown")
775 .to_string();
776
777 let bounds = item.get("bounds").and_then(|b| {
778 Some(Bounds {
779 x: b.get("x")?.as_f64()?,
780 y: b.get("y")?.as_f64()?,
781 width: b.get("width")?.as_f64()?,
782 height: b.get("height")?.as_f64()?,
783 })
784 });
785
786 out.push(LocatorMatch {
787 ref_id: ref_id.to_string(),
788 tag,
789 role: item.get("role").and_then(Value::as_str).map(String::from),
790 name: item.get("name").and_then(Value::as_str).map(String::from),
791 text: item.get("text").and_then(Value::as_str).map(String::from),
792 visible: item.get("visible").and_then(Value::as_bool).unwrap_or(true),
793 enabled: item.get("enabled").and_then(Value::as_bool).unwrap_or(true),
794 value: item.get("value").and_then(Value::as_str).map(String::from),
795 bounds,
796 });
797 }
798 out
799 }
800
801 async fn eval_on_element(
802 &self,
803 client: &mut VictauriClient,
804 js_body: &str,
805 ) -> Result<Value, TestError> {
806 let el = self.resolve_one(client).await?;
807 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
808 let code = format!(
809 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
810 if (!el) return null; {js_body} }})()"
811 );
812 client.eval_js(&code).await
813 }
814}
815
816impl fmt::Display for Locator {
817 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
818 match &self.strategy {
819 Strategy::Role(r) => write!(f, "role(\"{r}\")")?,
820 Strategy::Text(t) => write!(f, "text(\"{t}\")")?,
821 Strategy::TextExact(t) => write!(f, "text_exact(\"{t}\")")?,
822 Strategy::TestId(id) => write!(f, "test_id(\"{id}\")")?,
823 Strategy::Css(s) => write!(f, "css(\"{s}\")")?,
824 Strategy::Label(t) => write!(f, "label(\"{t}\")")?,
825 Strategy::Placeholder(t) => write!(f, "placeholder(\"{t}\")")?,
826 Strategy::AltText(a) => write!(f, "alt_text(\"{a}\")")?,
827 Strategy::Title(t) => write!(f, "title(\"{t}\")")?,
828 }
829
830 for filter in &self.filters {
831 match filter {
832 Filter::Text(t) => write!(f, ".and_text(\"{t}\")")?,
833 Filter::TextExact(t) => write!(f, ".and_text_exact(\"{t}\")")?,
834 Filter::Role(r) => write!(f, ".and_role(\"{r}\")")?,
835 Filter::Name(n) => write!(f, ".name(\"{n}\")")?,
836 Filter::Tag(t) => write!(f, ".and_tag(\"{t}\")")?,
837 Filter::HasAttribute(a, None) => write!(f, ".and_has_attribute(\"{a}\", None)")?,
838 Filter::HasAttribute(a, Some(v)) => {
839 write!(f, ".and_has_attribute(\"{a}\", Some(\"{v}\"))")?;
840 }
841 }
842 }
843
844 match &self.pick {
845 Pick::First => {}
846 Pick::Nth(n) => write!(f, ".nth({n})")?,
847 Pick::Last => write!(f, ".last()")?,
848 }
849
850 Ok(())
851 }
852}
853
854pub struct LocatorExpect<'a> {
861 locator: &'a Locator,
862 client: &'a mut VictauriClient,
863 timeout_ms: u64,
864 poll_ms: u64,
865 negated: bool,
866}
867
868impl<'a> LocatorExpect<'a> {
869 #[must_use]
871 pub fn timeout_ms(mut self, ms: u64) -> Self {
872 self.timeout_ms = ms;
873 self
874 }
875
876 #[must_use]
878 pub fn poll_ms(mut self, ms: u64) -> Self {
879 self.poll_ms = ms;
880 self
881 }
882
883 #[must_use]
885 #[allow(clippy::should_implement_trait)]
886 pub fn not(mut self) -> Self {
887 self.negated = !self.negated;
888 self
889 }
890
891 pub async fn to_be_visible(self) -> Result<(), TestError> {
899 let negated = self.negated;
900 let desc = if negated {
901 format!("{} to NOT be visible", self.locator)
902 } else {
903 format!("{} to be visible", self.locator)
904 };
905 self.poll_until_simple(|el| el.visible, &desc).await
906 }
907
908 pub async fn to_be_hidden(self) -> Result<(), TestError> {
914 let negated = self.negated;
915 let desc = if negated {
916 format!("{} to NOT be hidden", self.locator)
917 } else {
918 format!("{} to be hidden", self.locator)
919 };
920 let effective_negated = !negated;
922 self.poll_until_simple_with_negated(|el| el.visible, effective_negated, &desc)
923 .await
924 }
925
926 pub async fn to_be_enabled(self) -> Result<(), TestError> {
932 let negated = self.negated;
933 let desc = if negated {
934 format!("{} to NOT be enabled", self.locator)
935 } else {
936 format!("{} to be enabled", self.locator)
937 };
938 self.poll_until_simple(|el| el.enabled, &desc).await
939 }
940
941 pub async fn to_be_disabled(self) -> Result<(), TestError> {
947 let negated = self.negated;
948 let desc = if negated {
949 format!("{} to NOT be disabled", self.locator)
950 } else {
951 format!("{} to be disabled", self.locator)
952 };
953 let effective_negated = !negated;
954 self.poll_until_simple_with_negated(|el| el.enabled, effective_negated, &desc)
955 .await
956 }
957
958 pub async fn to_be_focused(self) -> Result<(), TestError> {
964 let negated = self.negated;
965 let desc = if negated {
966 format!("{} to NOT be focused", self.locator)
967 } else {
968 format!("{} to be focused", self.locator)
969 };
970 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
971 let poll = Duration::from_millis(self.poll_ms);
972 let locator = self.locator.clone();
973 let client = self.client;
974 loop {
975 let result = check_focused(&locator, client).await;
976 let condition_met = match result {
977 Ok(met) => {
978 if negated {
979 !met
980 } else {
981 met
982 }
983 }
984 Err(_) if negated => true,
985 Err(e) => return Err(e),
986 };
987 if condition_met {
988 return Ok(());
989 }
990 if Instant::now() >= deadline {
991 return Err(TestError::Timeout(format!(
992 "expected {desc} within {}ms",
993 deadline
994 .duration_since(Instant::now().checked_sub(poll).unwrap_or(Instant::now()))
995 .as_millis()
996 )));
997 }
998 tokio::time::sleep(poll).await;
999 }
1000 }
1001
1002 pub async fn to_have_text(self, expected: &str) -> Result<(), TestError> {
1008 let negated = self.negated;
1009 let desc = if negated {
1010 format!("{} to NOT have text \"{expected}\"", self.locator)
1011 } else {
1012 format!("{} to have text \"{expected}\"", self.locator)
1013 };
1014 let expected_owned = expected.to_string();
1015 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1016 let poll = Duration::from_millis(self.poll_ms);
1017 let locator = self.locator.clone();
1018 let client = self.client;
1019 loop {
1020 let result = check_text_content(&locator, client).await;
1021 let condition_met = match result {
1022 Ok(actual) => {
1023 let matches = actual.trim() == expected_owned.as_str();
1024 if negated { !matches } else { matches }
1025 }
1026 Err(_) if negated => true,
1027 Err(e) => return Err(e),
1028 };
1029 if condition_met {
1030 return Ok(());
1031 }
1032 if Instant::now() >= deadline {
1033 return Err(TestError::Timeout(format!(
1034 "expected {desc} within {}ms",
1035 self.timeout_ms
1036 )));
1037 }
1038 tokio::time::sleep(poll).await;
1039 }
1040 }
1041
1042 pub async fn to_contain_text(self, expected: &str) -> Result<(), TestError> {
1048 let negated = self.negated;
1049 let desc = if negated {
1050 format!("{} to NOT contain text \"{expected}\"", self.locator)
1051 } else {
1052 format!("{} to contain text \"{expected}\"", self.locator)
1053 };
1054 let expected_owned = expected.to_string();
1055 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1056 let poll = Duration::from_millis(self.poll_ms);
1057 let locator = self.locator.clone();
1058 let client = self.client;
1059 loop {
1060 let result = check_text_content(&locator, client).await;
1061 let condition_met = match result {
1062 Ok(actual) => {
1063 let matches = actual.contains(expected_owned.as_str());
1064 if negated { !matches } else { matches }
1065 }
1066 Err(_) if negated => true,
1067 Err(e) => return Err(e),
1068 };
1069 if condition_met {
1070 return Ok(());
1071 }
1072 if Instant::now() >= deadline {
1073 return Err(TestError::Timeout(format!(
1074 "expected {desc} within {}ms",
1075 self.timeout_ms
1076 )));
1077 }
1078 tokio::time::sleep(poll).await;
1079 }
1080 }
1081
1082 pub async fn to_have_value(self, expected: &str) -> Result<(), TestError> {
1088 let negated = self.negated;
1089 let desc = if negated {
1090 format!("{} to NOT have value \"{expected}\"", self.locator)
1091 } else {
1092 format!("{} to have value \"{expected}\"", self.locator)
1093 };
1094 let expected_owned = expected.to_string();
1095 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1096 let poll = Duration::from_millis(self.poll_ms);
1097 let locator = self.locator.clone();
1098 let client = self.client;
1099 loop {
1100 let result = check_input_value(&locator, client).await;
1101 let condition_met = match result {
1102 Ok(actual) => {
1103 let matches = actual == expected_owned;
1104 if negated { !matches } else { matches }
1105 }
1106 Err(_) if negated => true,
1107 Err(e) => return Err(e),
1108 };
1109 if condition_met {
1110 return Ok(());
1111 }
1112 if Instant::now() >= deadline {
1113 return Err(TestError::Timeout(format!(
1114 "expected {desc} within {}ms",
1115 self.timeout_ms
1116 )));
1117 }
1118 tokio::time::sleep(poll).await;
1119 }
1120 }
1121
1122 pub async fn to_have_attribute(self, attr_name: &str, value: &str) -> Result<(), TestError> {
1128 let negated = self.negated;
1129 let desc = if negated {
1130 format!(
1131 "{} to NOT have attribute {attr_name}=\"{value}\"",
1132 self.locator
1133 )
1134 } else {
1135 format!("{} to have attribute {attr_name}=\"{value}\"", self.locator)
1136 };
1137 let attr_owned = attr_name.to_string();
1138 let value_owned = value.to_string();
1139 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1140 let poll = Duration::from_millis(self.poll_ms);
1141 let locator = self.locator.clone();
1142 let client = self.client;
1143 loop {
1144 let result = check_attribute(&locator, client, &attr_owned).await;
1145 let condition_met = match result {
1146 Ok(actual) => {
1147 let matches = actual.as_deref() == Some(value_owned.as_str());
1148 if negated { !matches } else { matches }
1149 }
1150 Err(_) if negated => true,
1151 Err(e) => return Err(e),
1152 };
1153 if condition_met {
1154 return Ok(());
1155 }
1156 if Instant::now() >= deadline {
1157 return Err(TestError::Timeout(format!(
1158 "expected {desc} within {}ms",
1159 self.timeout_ms
1160 )));
1161 }
1162 tokio::time::sleep(poll).await;
1163 }
1164 }
1165
1166 pub async fn to_have_count(self, expected: usize) -> Result<(), TestError> {
1172 let negated = self.negated;
1173 let desc = if negated {
1174 format!("{} to NOT have count {expected}", self.locator)
1175 } else {
1176 format!("{} to have count {expected}", self.locator)
1177 };
1178 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1179 let poll = Duration::from_millis(self.poll_ms);
1180 let locator = self.locator.clone();
1181 let client = self.client;
1182 loop {
1183 let result = locator.resolve_all(client).await;
1184 let condition_met = match result {
1185 Ok(all) => {
1186 let matches = all.len() == expected;
1187 if negated { !matches } else { matches }
1188 }
1189 Err(_) if negated && expected != 0 => true,
1190 Err(_) if !negated && expected == 0 => true,
1191 Err(e) => return Err(e),
1192 };
1193 if condition_met {
1194 return Ok(());
1195 }
1196 if Instant::now() >= deadline {
1197 return Err(TestError::Timeout(format!(
1198 "expected {desc} within {}ms",
1199 self.timeout_ms
1200 )));
1201 }
1202 tokio::time::sleep(poll).await;
1203 }
1204 }
1205
1206 pub async fn to_be_checked(self) -> Result<(), TestError> {
1212 let negated = self.negated;
1213 let desc = if negated {
1214 format!("{} to NOT be checked", self.locator)
1215 } else {
1216 format!("{} to be checked", self.locator)
1217 };
1218 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1219 let poll = Duration::from_millis(self.poll_ms);
1220 let locator = self.locator.clone();
1221 let client = self.client;
1222 loop {
1223 let result = check_checked(&locator, client).await;
1224 let condition_met = match result {
1225 Ok(checked) => {
1226 if negated {
1227 !checked
1228 } else {
1229 checked
1230 }
1231 }
1232 Err(_) if negated => true,
1233 Err(e) => return Err(e),
1234 };
1235 if condition_met {
1236 return Ok(());
1237 }
1238 if Instant::now() >= deadline {
1239 return Err(TestError::Timeout(format!(
1240 "expected {desc} within {}ms",
1241 self.timeout_ms
1242 )));
1243 }
1244 tokio::time::sleep(poll).await;
1245 }
1246 }
1247
1248 pub async fn to_be_unchecked(self) -> Result<(), TestError> {
1254 let negated = self.negated;
1255 let desc = if negated {
1256 format!("{} to NOT be unchecked", self.locator)
1257 } else {
1258 format!("{} to be unchecked", self.locator)
1259 };
1260 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1261 let poll = Duration::from_millis(self.poll_ms);
1262 let locator = self.locator.clone();
1263 let client = self.client;
1264 loop {
1265 let result = check_checked(&locator, client).await;
1266 let condition_met = match result {
1267 Ok(checked) => {
1268 let unchecked = !checked;
1269 if negated { !unchecked } else { unchecked }
1270 }
1271 Err(_) if negated => true,
1272 Err(e) => return Err(e),
1273 };
1274 if condition_met {
1275 return Ok(());
1276 }
1277 if Instant::now() >= deadline {
1278 return Err(TestError::Timeout(format!(
1279 "expected {desc} within {}ms",
1280 self.timeout_ms
1281 )));
1282 }
1283 tokio::time::sleep(poll).await;
1284 }
1285 }
1286
1287 pub async fn to_be_attached(self) -> Result<(), TestError> {
1293 let negated = self.negated;
1294 let desc = if negated {
1295 format!("{} to NOT be attached", self.locator)
1296 } else {
1297 format!("{} to be attached", self.locator)
1298 };
1299 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1300 let poll = Duration::from_millis(self.poll_ms);
1301 let locator = self.locator.clone();
1302 let client = self.client;
1303 loop {
1304 let result = locator.resolve_one(client).await;
1305 let condition_met = match result {
1306 Ok(_) => !negated,
1307 Err(TestError::ElementNotFound(_)) => negated,
1308 Err(e) => return Err(e),
1309 };
1310 if condition_met {
1311 return Ok(());
1312 }
1313 if Instant::now() >= deadline {
1314 return Err(TestError::Timeout(format!(
1315 "expected {desc} within {}ms",
1316 self.timeout_ms
1317 )));
1318 }
1319 tokio::time::sleep(poll).await;
1320 }
1321 }
1322
1323 pub async fn to_be_detached(self) -> Result<(), TestError> {
1329 let negated = self.negated;
1330 let desc = if negated {
1331 format!("{} to NOT be detached", self.locator)
1332 } else {
1333 format!("{} to be detached", self.locator)
1334 };
1335 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1336 let poll = Duration::from_millis(self.poll_ms);
1337 let locator = self.locator.clone();
1338 let client = self.client;
1339 loop {
1340 let result = locator.resolve_one(client).await;
1341 let condition_met = match result {
1342 Ok(_) => negated,
1343 Err(TestError::ElementNotFound(_)) => !negated,
1344 Err(e) => return Err(e),
1345 };
1346 if condition_met {
1347 return Ok(());
1348 }
1349 if Instant::now() >= deadline {
1350 return Err(TestError::Timeout(format!(
1351 "expected {desc} within {}ms",
1352 self.timeout_ms
1353 )));
1354 }
1355 tokio::time::sleep(poll).await;
1356 }
1357 }
1358
1359 async fn poll_until_simple<F>(self, check: F, description: &str) -> Result<(), TestError>
1363 where
1364 F: Fn(&LocatorMatch) -> bool,
1365 {
1366 let negated = self.negated;
1367 self.poll_until_simple_with_negated(check, negated, description)
1368 .await
1369 }
1370
1371 async fn poll_until_simple_with_negated<F>(
1372 self,
1373 check: F,
1374 negated: bool,
1375 description: &str,
1376 ) -> Result<(), TestError>
1377 where
1378 F: Fn(&LocatorMatch) -> bool,
1379 {
1380 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1381 let poll = Duration::from_millis(self.poll_ms);
1382 let locator = self.locator.clone();
1383 let client = self.client;
1384 loop {
1385 let result = locator.resolve_one(client).await;
1386 let condition_met = match result {
1387 Ok(el) => {
1388 let raw = check(&el);
1389 if negated { !raw } else { raw }
1390 }
1391 Err(TestError::ElementNotFound(_)) if negated => true,
1392 Err(e @ TestError::ElementNotFound(_)) => {
1393 if Instant::now() >= deadline {
1394 return Err(e);
1395 }
1396 false
1397 }
1398 Err(e) => return Err(e),
1399 };
1400 if condition_met {
1401 return Ok(());
1402 }
1403 if Instant::now() >= deadline {
1404 return Err(TestError::Timeout(format!(
1405 "expected {description} within {}ms",
1406 self.timeout_ms
1407 )));
1408 }
1409 tokio::time::sleep(poll).await;
1410 }
1411 }
1412}
1413
1414async fn check_focused(locator: &Locator, client: &mut VictauriClient) -> Result<bool, TestError> {
1417 let el = locator.resolve_one(client).await?;
1418 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1419 let code = format!(
1420 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1421 if (!el) return false; return document.activeElement === el; }})()"
1422 );
1423 let val = client.eval_js(&code).await?;
1424 Ok(val.as_bool().unwrap_or(false))
1425}
1426
1427async fn check_text_content(
1428 locator: &Locator,
1429 client: &mut VictauriClient,
1430) -> Result<String, TestError> {
1431 let el = locator.resolve_one(client).await?;
1432 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1433 let code = format!(
1434 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1435 if (!el) return \"\"; return el.textContent || \"\"; }})()"
1436 );
1437 let val = client.eval_js(&code).await?;
1438 Ok(value_to_string(&val))
1439}
1440
1441async fn check_input_value(
1442 locator: &Locator,
1443 client: &mut VictauriClient,
1444) -> Result<String, TestError> {
1445 let el = locator.resolve_one(client).await?;
1446 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1447 let code = format!(
1448 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1449 if (!el) return \"\"; return el.value || \"\"; }})()"
1450 );
1451 let val = client.eval_js(&code).await?;
1452 Ok(value_to_string(&val))
1453}
1454
1455async fn check_attribute(
1456 locator: &Locator,
1457 client: &mut VictauriClient,
1458 attr_name: &str,
1459) -> Result<Option<String>, TestError> {
1460 let el = locator.resolve_one(client).await?;
1461 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1462 let escaped = attr_name.replace('\\', "\\\\").replace('"', "\\\"");
1463 let code = format!(
1464 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1465 if (!el) return null; return el.getAttribute(\"{escaped}\"); }})()"
1466 );
1467 let val = client.eval_js(&code).await?;
1468 if val.is_null() {
1469 Ok(None)
1470 } else {
1471 Ok(Some(value_to_string(&val)))
1472 }
1473}
1474
1475async fn check_checked(locator: &Locator, client: &mut VictauriClient) -> Result<bool, TestError> {
1476 let el = locator.resolve_one(client).await?;
1477 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1478 let code = format!(
1479 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1480 if (!el) return false; return !!el.checked; }})()"
1481 );
1482 let val = client.eval_js(&code).await?;
1483 Ok(val.as_bool().unwrap_or(false))
1484}
1485
1486fn value_to_string(val: &Value) -> String {
1489 match val {
1490 Value::String(s) => s.clone(),
1491 Value::Null => String::new(),
1492 other => other.to_string(),
1493 }
1494}
1495
1496#[cfg(test)]
1499mod tests {
1500 use super::*;
1501 use serde_json::json;
1502
1503 #[test]
1504 fn locator_role_build_query() {
1505 let loc = Locator::role("button");
1506 let q = loc.build_query();
1507 assert_eq!(q["role"], json!("button"));
1508 assert_eq!(q["max_results"], json!(50));
1509 }
1510
1511 #[test]
1512 fn locator_text_build_query() {
1513 let loc = Locator::text("Submit");
1514 let q = loc.build_query();
1515 assert_eq!(q["text"], json!("Submit"));
1516 }
1517
1518 #[test]
1519 fn locator_test_id_build_query() {
1520 let loc = Locator::test_id("email");
1521 let q = loc.build_query();
1522 assert_eq!(q["test_id"], json!("email"));
1523 }
1524
1525 #[test]
1526 fn locator_css_build_query() {
1527 let loc = Locator::css(".card > h2");
1528 let q = loc.build_query();
1529 assert_eq!(q["css"], json!(".card > h2"));
1530 }
1531
1532 #[test]
1533 fn locator_with_name_filter() {
1534 let loc = Locator::role("button").name("Submit");
1535 let q = loc.build_query();
1536 assert_eq!(q["role"], json!("button"));
1537 assert_eq!(q["name"], json!("Submit"));
1538 }
1539
1540 #[test]
1541 fn locator_with_tag_filter() {
1542 let loc = Locator::text("Click me").and_tag("button");
1543 let q = loc.build_query();
1544 assert_eq!(q["text"], json!("Click me"));
1545 assert_eq!(q["tag"], json!("button"));
1546 }
1547
1548 #[test]
1549 fn locator_nth_selection() {
1550 let loc = Locator::css("li").nth(3);
1551 match loc.pick {
1552 Pick::Nth(n) => assert_eq!(n, 3),
1553 _ => panic!("expected Pick::Nth"),
1554 }
1555 }
1556
1557 #[test]
1558 fn locator_first_last() {
1559 let first = Locator::css("p").first();
1560 assert!(matches!(first.pick, Pick::First));
1561
1562 let last = Locator::css("p").last();
1563 assert!(matches!(last.pick, Pick::Last));
1564 }
1565
1566 #[test]
1567 fn parse_elements_array() {
1568 let data = json!([
1569 {"ref_id": "e1", "tag": "button", "role": "button", "name": "OK", "text": "OK",
1570 "visible": true, "enabled": true, "value": null,
1571 "bounds": {"x": 10.0, "y": 20.0, "width": 80.0, "height": 30.0}},
1572 {"ref_id": "e2", "tag": "input", "role": "textbox", "name": null, "text": "",
1573 "visible": true, "enabled": false, "value": "hello",
1574 "bounds": {"x": 0.0, "y": 0.0, "width": 200.0, "height": 24.0}}
1575 ]);
1576 let elements = Locator::parse_elements(&data);
1577 assert_eq!(elements.len(), 2);
1578 assert_eq!(elements[0].ref_id, "e1");
1579 assert_eq!(elements[0].tag, "button");
1580 assert!(elements[0].visible);
1581 assert!(elements[0].enabled);
1582 assert_eq!(elements[0].bounds.unwrap().width, 80.0);
1583 assert_eq!(elements[1].ref_id, "e2");
1584 assert!(!elements[1].enabled);
1585 assert_eq!(elements[1].value.as_deref(), Some("hello"));
1586 }
1587
1588 #[test]
1589 fn parse_elements_object() {
1590 let data = json!({
1591 "elements": [
1592 {"ref_id": "e5", "tag": "div", "visible": true, "enabled": true}
1593 ]
1594 });
1595 let elements = Locator::parse_elements(&data);
1596 assert_eq!(elements.len(), 1);
1597 assert_eq!(elements[0].ref_id, "e5");
1598 assert_eq!(elements[0].tag, "div");
1599 }
1600
1601 #[test]
1602 fn parse_elements_empty() {
1603 let data = json!([]);
1604 let elements = Locator::parse_elements(&data);
1605 assert!(elements.is_empty());
1606
1607 let data2 = json!({"elements": []});
1608 let elements2 = Locator::parse_elements(&data2);
1609 assert!(elements2.is_empty());
1610
1611 let data3 = json!(null);
1612 let elements3 = Locator::parse_elements(&data3);
1613 assert!(elements3.is_empty());
1614 }
1615
1616 #[test]
1617 fn apply_filters_exact_text() {
1618 let loc = Locator::role("button").and_text_exact("Submit");
1619 let elements = vec![
1620 make_match("e1", "button", Some("Submit Form")),
1621 make_match("e2", "button", Some("Submit")),
1622 make_match("e3", "button", Some("Cancel")),
1623 ];
1624 let filtered = loc.apply_filters(elements);
1625 assert_eq!(filtered.len(), 1);
1626 assert_eq!(filtered[0].ref_id, "e2");
1627 }
1628
1629 #[test]
1630 fn apply_filters_role() {
1631 let loc = Locator::text("OK").and_role("button");
1632 let elements = vec![
1633 LocatorMatch {
1634 ref_id: "e1".into(),
1635 tag: "button".into(),
1636 role: Some("button".into()),
1637 name: None,
1638 text: Some("OK".into()),
1639 visible: true,
1640 enabled: true,
1641 value: None,
1642 bounds: None,
1643 },
1644 LocatorMatch {
1645 ref_id: "e2".into(),
1646 tag: "span".into(),
1647 role: Some("generic".into()),
1648 name: None,
1649 text: Some("OK".into()),
1650 visible: true,
1651 enabled: true,
1652 value: None,
1653 bounds: None,
1654 },
1655 ];
1656 let filtered = loc.apply_filters(elements);
1657 assert_eq!(filtered.len(), 1);
1658 assert_eq!(filtered[0].ref_id, "e1");
1659 }
1660
1661 #[test]
1662 fn apply_filters_tag() {
1663 let loc = Locator::role("button").and_tag("a");
1664 let elements = vec![
1665 LocatorMatch {
1666 ref_id: "e1".into(),
1667 tag: "button".into(),
1668 role: Some("button".into()),
1669 name: None,
1670 text: None,
1671 visible: true,
1672 enabled: true,
1673 value: None,
1674 bounds: None,
1675 },
1676 LocatorMatch {
1677 ref_id: "e2".into(),
1678 tag: "a".into(),
1679 role: Some("button".into()),
1680 name: None,
1681 text: None,
1682 visible: true,
1683 enabled: true,
1684 value: None,
1685 bounds: None,
1686 },
1687 ];
1688 let filtered = loc.apply_filters(elements);
1689 assert_eq!(filtered.len(), 1);
1690 assert_eq!(filtered[0].ref_id, "e2");
1691 }
1692
1693 #[test]
1694 fn locator_display_role() {
1695 let loc = Locator::role("button").name("Submit");
1696 assert_eq!(loc.to_string(), "role(\"button\").name(\"Submit\")");
1697 }
1698
1699 #[test]
1700 fn locator_display_css_nth() {
1701 let loc = Locator::css(".card").nth(2);
1702 assert_eq!(loc.to_string(), "css(\".card\").nth(2)");
1703 }
1704
1705 #[test]
1706 fn locator_clone_and_modify() {
1707 let base = Locator::role("button");
1708 let submit = base.clone().name("Submit");
1709 let cancel = base.clone().name("Cancel");
1710
1711 assert_eq!(base.to_string(), "role(\"button\")");
1712 assert_eq!(submit.to_string(), "role(\"button\").name(\"Submit\")");
1713 assert_eq!(cancel.to_string(), "role(\"button\").name(\"Cancel\")");
1714 }
1715
1716 #[test]
1717 fn locator_send_sync() {
1718 fn assert_send_sync<T: Send + Sync>() {}
1719 assert_send_sync::<Locator>();
1720 assert_send_sync::<LocatorMatch>();
1721 assert_send_sync::<Bounds>();
1722 }
1723
1724 #[test]
1725 fn locator_label_build_query() {
1726 let loc = Locator::label("Email");
1727 let q = loc.build_query();
1728 assert_eq!(q["label"], json!("Email"));
1729 }
1730
1731 #[test]
1732 fn locator_placeholder_build_query() {
1733 let loc = Locator::placeholder("Enter email");
1734 let q = loc.build_query();
1735 assert_eq!(q["placeholder"], json!("Enter email"));
1736 }
1737
1738 #[test]
1739 fn locator_alt_text_build_query() {
1740 let loc = Locator::alt_text("Logo");
1741 let q = loc.build_query();
1742 assert_eq!(q["alt"], json!("Logo"));
1743 }
1744
1745 #[test]
1746 fn locator_title_build_query() {
1747 let loc = Locator::title("Close");
1748 let q = loc.build_query();
1749 assert_eq!(q["title_attr"], json!("Close"));
1750 }
1751
1752 #[test]
1753 fn locator_text_exact_build_query() {
1754 let loc = Locator::text_exact("Submit");
1755 let q = loc.build_query();
1756 assert_eq!(q["text"], json!("Submit"));
1757 assert_eq!(q["exact"], json!(true));
1758 }
1759
1760 #[test]
1761 fn locator_display_all_strategies() {
1762 assert_eq!(Locator::text("hi").to_string(), "text(\"hi\")");
1763 assert_eq!(Locator::text_exact("hi").to_string(), "text_exact(\"hi\")");
1764 assert_eq!(Locator::test_id("x").to_string(), "test_id(\"x\")");
1765 assert_eq!(Locator::label("E").to_string(), "label(\"E\")");
1766 assert_eq!(Locator::placeholder("p").to_string(), "placeholder(\"p\")");
1767 assert_eq!(Locator::alt_text("a").to_string(), "alt_text(\"a\")");
1768 assert_eq!(Locator::title("t").to_string(), "title(\"t\")");
1769 }
1770
1771 #[test]
1772 fn locator_display_has_attribute() {
1773 let loc = Locator::css("input")
1774 .and_has_attribute("required", None)
1775 .and_has_attribute("type", Some("email"));
1776 assert_eq!(
1777 loc.to_string(),
1778 "css(\"input\").and_has_attribute(\"required\", None).and_has_attribute(\"type\", Some(\"email\"))"
1779 );
1780 }
1781
1782 #[test]
1783 fn locator_display_last() {
1784 let loc = Locator::role("listitem").last();
1785 assert_eq!(loc.to_string(), "role(\"listitem\").last()");
1786 }
1787
1788 #[test]
1789 fn locator_display_and_text() {
1790 let loc = Locator::role("link").and_text("docs");
1791 assert_eq!(loc.to_string(), "role(\"link\").and_text(\"docs\")");
1792 }
1793
1794 #[test]
1795 fn parse_elements_skips_missing_ref_id() {
1796 let data = json!([
1797 {"tag": "div", "visible": true, "enabled": true},
1798 {"ref_id": "e1", "tag": "span", "visible": true, "enabled": true}
1799 ]);
1800 let elements = Locator::parse_elements(&data);
1801 assert_eq!(elements.len(), 1);
1802 assert_eq!(elements[0].ref_id, "e1");
1803 }
1804
1805 #[test]
1806 fn pick_one_first() {
1807 let loc = Locator::css("p").first();
1808 let elements = vec![
1809 make_match("e1", "p", Some("first")),
1810 make_match("e2", "p", Some("second")),
1811 ];
1812 let picked = loc.pick_one(elements).unwrap();
1813 assert_eq!(picked.ref_id, "e1");
1814 }
1815
1816 #[test]
1817 fn pick_one_last() {
1818 let loc = Locator::css("p").last();
1819 let elements = vec![
1820 make_match("e1", "p", Some("first")),
1821 make_match("e2", "p", Some("second")),
1822 ];
1823 let picked = loc.pick_one(elements).unwrap();
1824 assert_eq!(picked.ref_id, "e2");
1825 }
1826
1827 #[test]
1828 fn pick_one_nth() {
1829 let loc = Locator::css("p").nth(1);
1830 let elements = vec![
1831 make_match("e1", "p", Some("first")),
1832 make_match("e2", "p", Some("second")),
1833 make_match("e3", "p", Some("third")),
1834 ];
1835 let picked = loc.pick_one(elements).unwrap();
1836 assert_eq!(picked.ref_id, "e2");
1837 }
1838
1839 #[test]
1840 fn pick_one_empty_returns_error() {
1841 let loc = Locator::css("p");
1842 let result = loc.pick_one(Vec::new());
1843 assert!(result.is_err());
1844 let err = result.unwrap_err();
1845 assert!(matches!(err, TestError::ElementNotFound(_)));
1846 }
1847
1848 #[test]
1849 fn pick_one_nth_out_of_bounds() {
1850 let loc = Locator::css("p").nth(5);
1851 let elements = vec![make_match("e1", "p", None)];
1852 let result = loc.pick_one(elements);
1853 assert!(result.is_err());
1854 }
1855
1856 #[test]
1857 fn apply_filters_name_case_insensitive() {
1858 let loc = Locator::role("button").name("submit");
1859 let elements = vec![
1860 LocatorMatch {
1861 ref_id: "e1".into(),
1862 tag: "button".into(),
1863 role: Some("button".into()),
1864 name: Some("Submit Form".into()),
1865 text: Some("Submit".into()),
1866 visible: true,
1867 enabled: true,
1868 value: None,
1869 bounds: None,
1870 },
1871 LocatorMatch {
1872 ref_id: "e2".into(),
1873 tag: "button".into(),
1874 role: Some("button".into()),
1875 name: Some("Cancel".into()),
1876 text: Some("Cancel".into()),
1877 visible: true,
1878 enabled: true,
1879 value: None,
1880 bounds: None,
1881 },
1882 ];
1883 let filtered = loc.apply_filters(elements);
1884 assert_eq!(filtered.len(), 1);
1885 assert_eq!(filtered[0].ref_id, "e1");
1886 }
1887
1888 #[test]
1889 fn apply_filters_text_case_insensitive() {
1890 let loc = Locator::role("button").and_text("submit");
1891 let elements = vec![
1892 make_match("e1", "button", Some("Submit Form")),
1893 make_match("e2", "button", Some("Cancel")),
1894 ];
1895 let filtered = loc.apply_filters(elements);
1896 assert_eq!(filtered.len(), 1);
1897 assert_eq!(filtered[0].ref_id, "e1");
1898 }
1899
1900 #[test]
1901 fn text_exact_strategy_filters_client_side() {
1902 let loc = Locator::text_exact("OK");
1903 let elements = vec![
1904 make_match("e1", "span", Some("OK")),
1905 make_match("e2", "span", Some("OK button")),
1906 ];
1907 let filtered = loc.apply_filters(elements);
1908 assert_eq!(filtered.len(), 1);
1909 assert_eq!(filtered[0].ref_id, "e1");
1910 }
1911
1912 #[test]
1913 fn bounds_deserialize() {
1914 let json_str = r#"{"x":10.5,"y":20.0,"width":100.0,"height":50.5}"#;
1915 let bounds: Bounds = serde_json::from_str(json_str).unwrap();
1916 assert_eq!(bounds.x, 10.5);
1917 assert_eq!(bounds.height, 50.5);
1918 }
1919
1920 #[test]
1921 fn bounds_serialize_roundtrip() {
1922 let bounds = Bounds {
1923 x: 1.0,
1924 y: 2.0,
1925 width: 3.0,
1926 height: 4.0,
1927 };
1928 let json = serde_json::to_string(&bounds).unwrap();
1929 let deserialized: Bounds = serde_json::from_str(&json).unwrap();
1930 assert_eq!(bounds, deserialized);
1931 }
1932
1933 #[test]
1934 fn value_to_string_converts_types() {
1935 assert_eq!(value_to_string(&json!("hello")), "hello");
1936 assert_eq!(value_to_string(&json!(null)), "");
1937 assert_eq!(value_to_string(&json!(42)), "42");
1938 assert_eq!(value_to_string(&json!(true)), "true");
1939 }
1940
1941 fn make_match(ref_id: &str, tag: &str, text: Option<&str>) -> LocatorMatch {
1944 LocatorMatch {
1945 ref_id: ref_id.into(),
1946 tag: tag.into(),
1947 role: None,
1948 name: None,
1949 text: text.map(String::from),
1950 visible: true,
1951 enabled: true,
1952 value: None,
1953 bounds: None,
1954 }
1955 }
1956}