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)]
136pub struct Locator {
137 strategy: Strategy,
138 filters: Vec<Filter>,
139 pick: Pick,
140}
141
142const _: () = {
144 fn _assert_send_sync<T: Send + Sync>() {}
145 fn _check() {
146 _assert_send_sync::<Locator>();
147 }
148};
149
150impl Locator {
151 #[must_use]
155 pub fn role(role: &str) -> Self {
156 Self {
157 strategy: Strategy::Role(role.to_string()),
158 filters: Vec::new(),
159 pick: Pick::First,
160 }
161 }
162
163 #[must_use]
165 pub fn text(text: &str) -> Self {
166 Self {
167 strategy: Strategy::Text(text.to_string()),
168 filters: Vec::new(),
169 pick: Pick::First,
170 }
171 }
172
173 #[must_use]
175 pub fn text_exact(text: &str) -> Self {
176 Self {
177 strategy: Strategy::TextExact(text.to_string()),
178 filters: Vec::new(),
179 pick: Pick::First,
180 }
181 }
182
183 #[must_use]
185 pub fn test_id(id: &str) -> Self {
186 Self {
187 strategy: Strategy::TestId(id.to_string()),
188 filters: Vec::new(),
189 pick: Pick::First,
190 }
191 }
192
193 #[must_use]
195 pub fn css(selector: &str) -> Self {
196 Self {
197 strategy: Strategy::Css(selector.to_string()),
198 filters: Vec::new(),
199 pick: Pick::First,
200 }
201 }
202
203 #[must_use]
205 pub fn label(text: &str) -> Self {
206 Self {
207 strategy: Strategy::Label(text.to_string()),
208 filters: Vec::new(),
209 pick: Pick::First,
210 }
211 }
212
213 #[must_use]
215 pub fn placeholder(text: &str) -> Self {
216 Self {
217 strategy: Strategy::Placeholder(text.to_string()),
218 filters: Vec::new(),
219 pick: Pick::First,
220 }
221 }
222
223 #[must_use]
225 pub fn alt_text(alt: &str) -> Self {
226 Self {
227 strategy: Strategy::AltText(alt.to_string()),
228 filters: Vec::new(),
229 pick: Pick::First,
230 }
231 }
232
233 #[must_use]
235 pub fn title(title: &str) -> Self {
236 Self {
237 strategy: Strategy::Title(title.to_string()),
238 filters: Vec::new(),
239 pick: Pick::First,
240 }
241 }
242
243 #[must_use]
247 pub fn and_text(mut self, text: &str) -> Self {
248 self.filters.push(Filter::Text(text.to_string()));
249 self
250 }
251
252 #[must_use]
254 pub fn and_text_exact(mut self, text: &str) -> Self {
255 self.filters.push(Filter::TextExact(text.to_string()));
256 self
257 }
258
259 #[must_use]
261 pub fn and_role(mut self, role: &str) -> Self {
262 self.filters.push(Filter::Role(role.to_string()));
263 self
264 }
265
266 #[must_use]
268 pub fn name(mut self, name: &str) -> Self {
269 self.filters.push(Filter::Name(name.to_string()));
270 self
271 }
272
273 #[must_use]
275 pub fn and_tag(mut self, tag: &str) -> Self {
276 self.filters.push(Filter::Tag(tag.to_string()));
277 self
278 }
279
280 #[must_use]
282 pub fn and_has_attribute(mut self, attr_name: &str, attr_value: Option<&str>) -> Self {
283 self.filters.push(Filter::HasAttribute(
284 attr_name.to_string(),
285 attr_value.map(String::from),
286 ));
287 self
288 }
289
290 #[must_use]
292 pub fn nth(mut self, n: usize) -> Self {
293 self.pick = Pick::Nth(n);
294 self
295 }
296
297 #[must_use]
299 pub fn first(mut self) -> Self {
300 self.pick = Pick::First;
301 self
302 }
303
304 #[must_use]
306 pub fn last(mut self) -> Self {
307 self.pick = Pick::Last;
308 self
309 }
310
311 pub async fn click(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
319 let el = self.resolve_one(client).await?;
320 client.click(&el.ref_id).await
321 }
322
323 pub async fn double_click(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
329 let el = self.resolve_one(client).await?;
330 client.double_click(&el.ref_id).await
331 }
332
333 pub async fn fill(&self, client: &mut VictauriClient, value: &str) -> Result<Value, TestError> {
339 let el = self.resolve_one(client).await?;
340 client.fill(&el.ref_id, value).await
341 }
342
343 pub async fn type_text(
349 &self,
350 client: &mut VictauriClient,
351 text: &str,
352 ) -> Result<Value, TestError> {
353 let el = self.resolve_one(client).await?;
354 client.type_text(&el.ref_id, text).await
355 }
356
357 pub async fn press_key(
363 &self,
364 client: &mut VictauriClient,
365 key: &str,
366 ) -> Result<Value, TestError> {
367 let _el = self.resolve_one(client).await?;
368 client.press_key(key).await
369 }
370
371 pub async fn hover(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
377 let el = self.resolve_one(client).await?;
378 client.hover(&el.ref_id).await
379 }
380
381 pub async fn focus(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
387 let el = self.resolve_one(client).await?;
388 client.focus(&el.ref_id).await
389 }
390
391 pub async fn blur(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
397 let _el = self.resolve_one(client).await?;
398 client.eval_js("document.activeElement?.blur()").await
399 }
400
401 pub async fn scroll_into_view(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
407 let el = self.resolve_one(client).await?;
408 client.scroll_to(&el.ref_id).await
409 }
410
411 pub async fn select_option(
417 &self,
418 client: &mut VictauriClient,
419 values: &[&str],
420 ) -> Result<Value, TestError> {
421 let el = self.resolve_one(client).await?;
422 client.select_option(&el.ref_id, values).await
423 }
424
425 pub async fn check(&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 = true; \
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 uncheck(&self, client: &mut VictauriClient) -> Result<Value, TestError> {
450 let el = self.resolve_one(client).await?;
451 let code = format!(
452 "(function() {{ var el = window.__VICTAURI__?.getRef({}); \
453 if (!el) return null; \
454 if (el.checked) {{ el.checked = false; \
455 el.dispatchEvent(new Event('change', {{bubbles:true}})); \
456 el.dispatchEvent(new Event('input', {{bubbles:true}})); }} \
457 return true; }})()",
458 serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()),
459 );
460 client.eval_js(&code).await
461 }
462
463 pub async fn text_content(&self, client: &mut VictauriClient) -> Result<String, TestError> {
471 let val = self
472 .eval_on_element(client, "return el.textContent || \"\";")
473 .await?;
474 Ok(value_to_string(&val))
475 }
476
477 pub async fn inner_text(&self, client: &mut VictauriClient) -> Result<String, TestError> {
483 let val = self
484 .eval_on_element(client, "return el.innerText || \"\";")
485 .await?;
486 Ok(value_to_string(&val))
487 }
488
489 pub async fn input_value(&self, client: &mut VictauriClient) -> Result<String, TestError> {
495 let val = self
496 .eval_on_element(client, "return el.value || \"\";")
497 .await?;
498 Ok(value_to_string(&val))
499 }
500
501 pub async fn is_visible(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
507 let el = self.resolve_one(client).await?;
508 Ok(el.visible)
509 }
510
511 pub async fn is_enabled(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
517 let el = self.resolve_one(client).await?;
518 Ok(el.enabled)
519 }
520
521 pub async fn is_checked(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
527 let val = self.eval_on_element(client, "return !!el.checked;").await?;
528 Ok(val.as_bool().unwrap_or(false))
529 }
530
531 pub async fn is_focused(&self, client: &mut VictauriClient) -> Result<bool, TestError> {
537 let val = self
538 .eval_on_element(client, "return document.activeElement === el;")
539 .await?;
540 Ok(val.as_bool().unwrap_or(false))
541 }
542
543 pub async fn count(&self, client: &mut VictauriClient) -> Result<usize, TestError> {
549 let all = self.resolve_all(client).await?;
550 Ok(all.len())
551 }
552
553 pub async fn bounding_box(
559 &self,
560 client: &mut VictauriClient,
561 ) -> Result<Option<Bounds>, TestError> {
562 let el = self.resolve_one(client).await?;
563 Ok(el.bounds)
564 }
565
566 pub async fn get_attribute(
572 &self,
573 client: &mut VictauriClient,
574 attr_name: &str,
575 ) -> Result<Option<String>, TestError> {
576 let escaped = attr_name.replace('\\', "\\\\").replace('"', "\\\"");
577 let js_body = format!("return el.getAttribute(\"{escaped}\");");
578 let val = self.eval_on_element(client, &js_body).await?;
579 if val.is_null() {
580 Ok(None)
581 } else {
582 Ok(Some(value_to_string(&val)))
583 }
584 }
585
586 pub async fn all(&self, client: &mut VictauriClient) -> Result<Vec<LocatorMatch>, TestError> {
592 self.resolve_all(client).await
593 }
594
595 pub async fn all_text_contents(
601 &self,
602 client: &mut VictauriClient,
603 ) -> Result<Vec<String>, TestError> {
604 let elements = self.resolve_all(client).await?;
605 let mut texts = Vec::with_capacity(elements.len());
606 for el in &elements {
607 let code = format!(
608 "(function() {{ var el = window.__VICTAURI__?.getRef({}); \
609 if (!el) return \"\"; \
610 return el.textContent || \"\"; }})()",
611 serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string()),
612 );
613 let val = client.eval_js(&code).await?;
614 texts.push(value_to_string(&val));
615 }
616 Ok(texts)
617 }
618
619 pub fn expect<'a>(&'a self, client: &'a mut VictauriClient) -> LocatorExpect<'a> {
623 LocatorExpect {
624 locator: self,
625 client,
626 timeout_ms: 5000,
627 poll_ms: 200,
628 negated: false,
629 }
630 }
631
632 async fn resolve_all(
635 &self,
636 client: &mut VictauriClient,
637 ) -> Result<Vec<LocatorMatch>, TestError> {
638 let query = self.build_query();
639 let result = client.find_elements(query).await?;
640 let mut elements = Self::parse_elements(&result);
641 elements = self.apply_filters(elements);
642 Ok(elements)
643 }
644
645 async fn resolve_one(&self, client: &mut VictauriClient) -> Result<LocatorMatch, TestError> {
646 let all = self.resolve_all(client).await?;
647 self.pick_one(all)
648 }
649
650 fn pick_one(&self, all: Vec<LocatorMatch>) -> Result<LocatorMatch, TestError> {
651 if all.is_empty() {
652 return Err(TestError::ElementNotFound(format!(
653 "no elements match {self}"
654 )));
655 }
656 match self.pick {
657 Pick::First => Ok(all.into_iter().next().expect("checked non-empty")),
658 Pick::Last => Ok(all.into_iter().last().expect("checked non-empty")),
659 Pick::Nth(n) => {
660 let total = all.len();
661 all.into_iter().nth(n).ok_or_else(|| {
662 TestError::ElementNotFound(format!(
663 "{self}: wanted index {n} but only {total} elements matched"
664 ))
665 })
666 }
667 }
668 }
669
670 fn build_query(&self) -> Value {
671 let mut query = json!({});
672 match &self.strategy {
673 Strategy::Role(r) => {
674 query["role"] = json!(r);
675 }
676 Strategy::Text(t) => {
677 query["text"] = json!(t);
678 }
679 Strategy::TextExact(t) => {
680 query["text"] = json!(t);
681 query["exact"] = json!(true);
682 }
683 Strategy::TestId(id) => {
684 query["test_id"] = json!(id);
685 }
686 Strategy::Css(sel) => {
687 query["css"] = json!(sel);
688 }
689 Strategy::Label(t) => {
690 query["label"] = json!(t);
691 }
692 Strategy::Placeholder(t) => {
693 query["placeholder"] = json!(t);
694 }
695 Strategy::AltText(a) => {
696 query["alt"] = json!(a);
697 }
698 Strategy::Title(t) => {
699 query["title_attr"] = json!(t);
700 }
701 }
702
703 for filter in &self.filters {
705 match filter {
706 Filter::Role(r) => {
707 query["role"] = json!(r);
708 }
709 Filter::Name(n) => {
710 query["name"] = json!(n);
711 }
712 Filter::Tag(t) => {
713 query["tag"] = json!(t);
714 }
715 Filter::Text(_) | Filter::TextExact(_) | Filter::HasAttribute(_, _) => {}
717 }
718 }
719
720 query["max_results"] = json!(50);
721 query
722 }
723
724 fn apply_filters(&self, elements: Vec<LocatorMatch>) -> Vec<LocatorMatch> {
725 let mut result = elements;
726
727 if let Strategy::TextExact(ref expected) = self.strategy {
730 result.retain(|el| {
731 el.text
732 .as_deref()
733 .is_some_and(|t| t.trim() == expected.as_str())
734 });
735 }
736
737 for filter in &self.filters {
738 match filter {
739 Filter::Text(expected) => {
740 let lower = expected.to_lowercase();
741 result.retain(|el| {
742 el.text
743 .as_deref()
744 .is_some_and(|t| t.to_lowercase().contains(&lower))
745 });
746 }
747 Filter::TextExact(expected) => {
748 result.retain(|el| {
749 el.text
750 .as_deref()
751 .is_some_and(|t| t.trim() == expected.as_str())
752 });
753 }
754 Filter::Role(expected) => {
755 result.retain(|el| el.role.as_deref().is_some_and(|r| r == expected.as_str()));
756 }
757 Filter::Name(expected) => {
758 let lower = expected.to_lowercase();
759 result.retain(|el| {
760 el.name
761 .as_deref()
762 .is_some_and(|n| n.to_lowercase().contains(&lower))
763 });
764 }
765 Filter::Tag(expected) => {
766 result.retain(|el| el.tag == *expected);
767 }
768 Filter::HasAttribute(_, _) => {}
770 }
771 }
772
773 result
774 }
775
776 fn parse_elements(result: &Value) -> Vec<LocatorMatch> {
777 let array = result
778 .as_array()
779 .or_else(|| result.get("elements").and_then(Value::as_array));
780
781 let Some(arr) = array else {
782 return Vec::new();
783 };
784
785 let mut out = Vec::with_capacity(arr.len());
786 for item in arr {
787 let Some(ref_id) = item.get("ref_id").and_then(Value::as_str) else {
788 continue;
789 };
790 let tag = item
791 .get("tag")
792 .and_then(Value::as_str)
793 .unwrap_or("unknown")
794 .to_string();
795
796 let bounds = item.get("bounds").and_then(|b| {
797 Some(Bounds {
798 x: b.get("x")?.as_f64()?,
799 y: b.get("y")?.as_f64()?,
800 width: b.get("width")?.as_f64()?,
801 height: b.get("height")?.as_f64()?,
802 })
803 });
804
805 out.push(LocatorMatch {
806 ref_id: ref_id.to_string(),
807 tag,
808 role: item.get("role").and_then(Value::as_str).map(String::from),
809 name: item.get("name").and_then(Value::as_str).map(String::from),
810 text: item.get("text").and_then(Value::as_str).map(String::from),
811 visible: item.get("visible").and_then(Value::as_bool).unwrap_or(true),
812 enabled: item.get("enabled").and_then(Value::as_bool).unwrap_or(true),
813 value: item.get("value").and_then(Value::as_str).map(String::from),
814 bounds,
815 });
816 }
817 out
818 }
819
820 async fn eval_on_element(
821 &self,
822 client: &mut VictauriClient,
823 js_body: &str,
824 ) -> Result<Value, TestError> {
825 let el = self.resolve_one(client).await?;
826 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
827 let code = format!(
828 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
829 if (!el) return null; {js_body} }})()"
830 );
831 client.eval_js(&code).await
832 }
833}
834
835impl fmt::Display for Locator {
836 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
837 match &self.strategy {
838 Strategy::Role(r) => write!(f, "role(\"{r}\")")?,
839 Strategy::Text(t) => write!(f, "text(\"{t}\")")?,
840 Strategy::TextExact(t) => write!(f, "text_exact(\"{t}\")")?,
841 Strategy::TestId(id) => write!(f, "test_id(\"{id}\")")?,
842 Strategy::Css(s) => write!(f, "css(\"{s}\")")?,
843 Strategy::Label(t) => write!(f, "label(\"{t}\")")?,
844 Strategy::Placeholder(t) => write!(f, "placeholder(\"{t}\")")?,
845 Strategy::AltText(a) => write!(f, "alt_text(\"{a}\")")?,
846 Strategy::Title(t) => write!(f, "title(\"{t}\")")?,
847 }
848
849 for filter in &self.filters {
850 match filter {
851 Filter::Text(t) => write!(f, ".and_text(\"{t}\")")?,
852 Filter::TextExact(t) => write!(f, ".and_text_exact(\"{t}\")")?,
853 Filter::Role(r) => write!(f, ".and_role(\"{r}\")")?,
854 Filter::Name(n) => write!(f, ".name(\"{n}\")")?,
855 Filter::Tag(t) => write!(f, ".and_tag(\"{t}\")")?,
856 Filter::HasAttribute(a, None) => write!(f, ".and_has_attribute(\"{a}\", None)")?,
857 Filter::HasAttribute(a, Some(v)) => {
858 write!(f, ".and_has_attribute(\"{a}\", Some(\"{v}\"))")?;
859 }
860 }
861 }
862
863 match &self.pick {
864 Pick::First => {}
865 Pick::Nth(n) => write!(f, ".nth({n})")?,
866 Pick::Last => write!(f, ".last()")?,
867 }
868
869 Ok(())
870 }
871}
872
873pub struct LocatorExpect<'a> {
880 locator: &'a Locator,
881 client: &'a mut VictauriClient,
882 timeout_ms: u64,
883 poll_ms: u64,
884 negated: bool,
885}
886
887impl<'a> LocatorExpect<'a> {
888 #[must_use]
890 pub fn timeout_ms(mut self, ms: u64) -> Self {
891 self.timeout_ms = ms;
892 self
893 }
894
895 #[must_use]
897 pub fn poll_ms(mut self, ms: u64) -> Self {
898 self.poll_ms = ms;
899 self
900 }
901
902 #[must_use]
904 #[allow(clippy::should_implement_trait)]
905 pub fn not(mut self) -> Self {
906 self.negated = !self.negated;
907 self
908 }
909
910 pub async fn to_be_visible(self) -> Result<(), TestError> {
918 let negated = self.negated;
919 let desc = if negated {
920 format!("{} to NOT be visible", self.locator)
921 } else {
922 format!("{} to be visible", self.locator)
923 };
924 self.poll_until_simple(|el| el.visible, &desc).await
925 }
926
927 pub async fn to_be_hidden(self) -> Result<(), TestError> {
933 let negated = self.negated;
934 let desc = if negated {
935 format!("{} to NOT be hidden", self.locator)
936 } else {
937 format!("{} to be hidden", self.locator)
938 };
939 let effective_negated = !negated;
941 self.poll_until_simple_with_negated(|el| el.visible, effective_negated, &desc)
942 .await
943 }
944
945 pub async fn to_be_enabled(self) -> Result<(), TestError> {
951 let negated = self.negated;
952 let desc = if negated {
953 format!("{} to NOT be enabled", self.locator)
954 } else {
955 format!("{} to be enabled", self.locator)
956 };
957 self.poll_until_simple(|el| el.enabled, &desc).await
958 }
959
960 pub async fn to_be_disabled(self) -> Result<(), TestError> {
966 let negated = self.negated;
967 let desc = if negated {
968 format!("{} to NOT be disabled", self.locator)
969 } else {
970 format!("{} to be disabled", self.locator)
971 };
972 let effective_negated = !negated;
973 self.poll_until_simple_with_negated(|el| el.enabled, effective_negated, &desc)
974 .await
975 }
976
977 pub async fn to_be_focused(self) -> Result<(), TestError> {
983 let negated = self.negated;
984 let desc = if negated {
985 format!("{} to NOT be focused", self.locator)
986 } else {
987 format!("{} to be focused", self.locator)
988 };
989 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
990 let poll = Duration::from_millis(self.poll_ms);
991 let locator = self.locator.clone();
992 let client = self.client;
993 loop {
994 let result = check_focused(&locator, client).await;
995 let condition_met = match result {
996 Ok(met) => {
997 if negated {
998 !met
999 } else {
1000 met
1001 }
1002 }
1003 Err(_) if negated => true,
1004 Err(e) => return Err(e),
1005 };
1006 if condition_met {
1007 return Ok(());
1008 }
1009 if Instant::now() >= deadline {
1010 return Err(TestError::Timeout(format!(
1011 "expected {desc} within {}ms",
1012 deadline
1013 .duration_since(Instant::now().checked_sub(poll).unwrap_or(Instant::now()))
1014 .as_millis()
1015 )));
1016 }
1017 tokio::time::sleep(poll).await;
1018 }
1019 }
1020
1021 pub async fn to_have_text(self, expected: &str) -> Result<(), TestError> {
1027 let negated = self.negated;
1028 let desc = if negated {
1029 format!("{} to NOT have text \"{expected}\"", self.locator)
1030 } else {
1031 format!("{} to have text \"{expected}\"", self.locator)
1032 };
1033 let expected_owned = expected.to_string();
1034 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1035 let poll = Duration::from_millis(self.poll_ms);
1036 let locator = self.locator.clone();
1037 let client = self.client;
1038 loop {
1039 let result = check_text_content(&locator, client).await;
1040 let condition_met = match result {
1041 Ok(actual) => {
1042 let matches = actual.trim() == expected_owned.as_str();
1043 if negated { !matches } else { matches }
1044 }
1045 Err(_) if negated => true,
1046 Err(e) => return Err(e),
1047 };
1048 if condition_met {
1049 return Ok(());
1050 }
1051 if Instant::now() >= deadline {
1052 return Err(TestError::Timeout(format!(
1053 "expected {desc} within {}ms",
1054 self.timeout_ms
1055 )));
1056 }
1057 tokio::time::sleep(poll).await;
1058 }
1059 }
1060
1061 pub async fn to_contain_text(self, expected: &str) -> Result<(), TestError> {
1067 let negated = self.negated;
1068 let desc = if negated {
1069 format!("{} to NOT contain text \"{expected}\"", self.locator)
1070 } else {
1071 format!("{} to contain text \"{expected}\"", self.locator)
1072 };
1073 let expected_owned = expected.to_string();
1074 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1075 let poll = Duration::from_millis(self.poll_ms);
1076 let locator = self.locator.clone();
1077 let client = self.client;
1078 loop {
1079 let result = check_text_content(&locator, client).await;
1080 let condition_met = match result {
1081 Ok(actual) => {
1082 let matches = actual.contains(expected_owned.as_str());
1083 if negated { !matches } else { matches }
1084 }
1085 Err(_) if negated => true,
1086 Err(e) => return Err(e),
1087 };
1088 if condition_met {
1089 return Ok(());
1090 }
1091 if Instant::now() >= deadline {
1092 return Err(TestError::Timeout(format!(
1093 "expected {desc} within {}ms",
1094 self.timeout_ms
1095 )));
1096 }
1097 tokio::time::sleep(poll).await;
1098 }
1099 }
1100
1101 pub async fn to_have_value(self, expected: &str) -> Result<(), TestError> {
1107 let negated = self.negated;
1108 let desc = if negated {
1109 format!("{} to NOT have value \"{expected}\"", self.locator)
1110 } else {
1111 format!("{} to have value \"{expected}\"", self.locator)
1112 };
1113 let expected_owned = expected.to_string();
1114 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1115 let poll = Duration::from_millis(self.poll_ms);
1116 let locator = self.locator.clone();
1117 let client = self.client;
1118 loop {
1119 let result = check_input_value(&locator, client).await;
1120 let condition_met = match result {
1121 Ok(actual) => {
1122 let matches = actual == expected_owned;
1123 if negated { !matches } else { matches }
1124 }
1125 Err(_) if negated => true,
1126 Err(e) => return Err(e),
1127 };
1128 if condition_met {
1129 return Ok(());
1130 }
1131 if Instant::now() >= deadline {
1132 return Err(TestError::Timeout(format!(
1133 "expected {desc} within {}ms",
1134 self.timeout_ms
1135 )));
1136 }
1137 tokio::time::sleep(poll).await;
1138 }
1139 }
1140
1141 pub async fn to_have_attribute(self, attr_name: &str, value: &str) -> Result<(), TestError> {
1147 let negated = self.negated;
1148 let desc = if negated {
1149 format!(
1150 "{} to NOT have attribute {attr_name}=\"{value}\"",
1151 self.locator
1152 )
1153 } else {
1154 format!("{} to have attribute {attr_name}=\"{value}\"", self.locator)
1155 };
1156 let attr_owned = attr_name.to_string();
1157 let value_owned = value.to_string();
1158 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1159 let poll = Duration::from_millis(self.poll_ms);
1160 let locator = self.locator.clone();
1161 let client = self.client;
1162 loop {
1163 let result = check_attribute(&locator, client, &attr_owned).await;
1164 let condition_met = match result {
1165 Ok(actual) => {
1166 let matches = actual.as_deref() == Some(value_owned.as_str());
1167 if negated { !matches } else { matches }
1168 }
1169 Err(_) if negated => true,
1170 Err(e) => return Err(e),
1171 };
1172 if condition_met {
1173 return Ok(());
1174 }
1175 if Instant::now() >= deadline {
1176 return Err(TestError::Timeout(format!(
1177 "expected {desc} within {}ms",
1178 self.timeout_ms
1179 )));
1180 }
1181 tokio::time::sleep(poll).await;
1182 }
1183 }
1184
1185 pub async fn to_have_count(self, expected: usize) -> Result<(), TestError> {
1191 let negated = self.negated;
1192 let desc = if negated {
1193 format!("{} to NOT have count {expected}", self.locator)
1194 } else {
1195 format!("{} to have count {expected}", self.locator)
1196 };
1197 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1198 let poll = Duration::from_millis(self.poll_ms);
1199 let locator = self.locator.clone();
1200 let client = self.client;
1201 loop {
1202 let result = locator.resolve_all(client).await;
1203 let condition_met = match result {
1204 Ok(all) => {
1205 let matches = all.len() == expected;
1206 if negated { !matches } else { matches }
1207 }
1208 Err(_) if negated && expected != 0 => true,
1209 Err(_) if !negated && expected == 0 => true,
1210 Err(e) => return Err(e),
1211 };
1212 if condition_met {
1213 return Ok(());
1214 }
1215 if Instant::now() >= deadline {
1216 return Err(TestError::Timeout(format!(
1217 "expected {desc} within {}ms",
1218 self.timeout_ms
1219 )));
1220 }
1221 tokio::time::sleep(poll).await;
1222 }
1223 }
1224
1225 pub async fn to_be_checked(self) -> Result<(), TestError> {
1231 let negated = self.negated;
1232 let desc = if negated {
1233 format!("{} to NOT be checked", self.locator)
1234 } else {
1235 format!("{} to be checked", self.locator)
1236 };
1237 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1238 let poll = Duration::from_millis(self.poll_ms);
1239 let locator = self.locator.clone();
1240 let client = self.client;
1241 loop {
1242 let result = check_checked(&locator, client).await;
1243 let condition_met = match result {
1244 Ok(checked) => {
1245 if negated {
1246 !checked
1247 } else {
1248 checked
1249 }
1250 }
1251 Err(_) if negated => true,
1252 Err(e) => return Err(e),
1253 };
1254 if condition_met {
1255 return Ok(());
1256 }
1257 if Instant::now() >= deadline {
1258 return Err(TestError::Timeout(format!(
1259 "expected {desc} within {}ms",
1260 self.timeout_ms
1261 )));
1262 }
1263 tokio::time::sleep(poll).await;
1264 }
1265 }
1266
1267 pub async fn to_be_unchecked(self) -> Result<(), TestError> {
1273 let negated = self.negated;
1274 let desc = if negated {
1275 format!("{} to NOT be unchecked", self.locator)
1276 } else {
1277 format!("{} to be unchecked", self.locator)
1278 };
1279 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1280 let poll = Duration::from_millis(self.poll_ms);
1281 let locator = self.locator.clone();
1282 let client = self.client;
1283 loop {
1284 let result = check_checked(&locator, client).await;
1285 let condition_met = match result {
1286 Ok(checked) => {
1287 let unchecked = !checked;
1288 if negated { !unchecked } else { unchecked }
1289 }
1290 Err(_) if negated => true,
1291 Err(e) => return Err(e),
1292 };
1293 if condition_met {
1294 return Ok(());
1295 }
1296 if Instant::now() >= deadline {
1297 return Err(TestError::Timeout(format!(
1298 "expected {desc} within {}ms",
1299 self.timeout_ms
1300 )));
1301 }
1302 tokio::time::sleep(poll).await;
1303 }
1304 }
1305
1306 pub async fn to_be_attached(self) -> Result<(), TestError> {
1312 let negated = self.negated;
1313 let desc = if negated {
1314 format!("{} to NOT be attached", self.locator)
1315 } else {
1316 format!("{} to be attached", self.locator)
1317 };
1318 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1319 let poll = Duration::from_millis(self.poll_ms);
1320 let locator = self.locator.clone();
1321 let client = self.client;
1322 loop {
1323 let result = locator.resolve_one(client).await;
1324 let condition_met = match result {
1325 Ok(_) => !negated,
1326 Err(TestError::ElementNotFound(_)) => negated,
1327 Err(e) => return Err(e),
1328 };
1329 if condition_met {
1330 return Ok(());
1331 }
1332 if Instant::now() >= deadline {
1333 return Err(TestError::Timeout(format!(
1334 "expected {desc} within {}ms",
1335 self.timeout_ms
1336 )));
1337 }
1338 tokio::time::sleep(poll).await;
1339 }
1340 }
1341
1342 pub async fn to_be_detached(self) -> Result<(), TestError> {
1348 let negated = self.negated;
1349 let desc = if negated {
1350 format!("{} to NOT be detached", self.locator)
1351 } else {
1352 format!("{} to be detached", self.locator)
1353 };
1354 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1355 let poll = Duration::from_millis(self.poll_ms);
1356 let locator = self.locator.clone();
1357 let client = self.client;
1358 loop {
1359 let result = locator.resolve_one(client).await;
1360 let condition_met = match result {
1361 Ok(_) => negated,
1362 Err(TestError::ElementNotFound(_)) => !negated,
1363 Err(e) => return Err(e),
1364 };
1365 if condition_met {
1366 return Ok(());
1367 }
1368 if Instant::now() >= deadline {
1369 return Err(TestError::Timeout(format!(
1370 "expected {desc} within {}ms",
1371 self.timeout_ms
1372 )));
1373 }
1374 tokio::time::sleep(poll).await;
1375 }
1376 }
1377
1378 async fn poll_until_simple<F>(self, check: F, description: &str) -> Result<(), TestError>
1382 where
1383 F: Fn(&LocatorMatch) -> bool,
1384 {
1385 let negated = self.negated;
1386 self.poll_until_simple_with_negated(check, negated, description)
1387 .await
1388 }
1389
1390 async fn poll_until_simple_with_negated<F>(
1391 self,
1392 check: F,
1393 negated: bool,
1394 description: &str,
1395 ) -> Result<(), TestError>
1396 where
1397 F: Fn(&LocatorMatch) -> bool,
1398 {
1399 let deadline = Instant::now() + Duration::from_millis(self.timeout_ms);
1400 let poll = Duration::from_millis(self.poll_ms);
1401 let locator = self.locator.clone();
1402 let client = self.client;
1403 loop {
1404 let result = locator.resolve_one(client).await;
1405 let condition_met = match result {
1406 Ok(el) => {
1407 let raw = check(&el);
1408 if negated { !raw } else { raw }
1409 }
1410 Err(TestError::ElementNotFound(_)) if negated => true,
1411 Err(e @ TestError::ElementNotFound(_)) => {
1412 if Instant::now() >= deadline {
1413 return Err(e);
1414 }
1415 false
1416 }
1417 Err(e) => return Err(e),
1418 };
1419 if condition_met {
1420 return Ok(());
1421 }
1422 if Instant::now() >= deadline {
1423 return Err(TestError::Timeout(format!(
1424 "expected {description} within {}ms",
1425 self.timeout_ms
1426 )));
1427 }
1428 tokio::time::sleep(poll).await;
1429 }
1430 }
1431}
1432
1433async fn check_focused(locator: &Locator, client: &mut VictauriClient) -> Result<bool, TestError> {
1436 let el = locator.resolve_one(client).await?;
1437 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1438 let code = format!(
1439 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1440 if (!el) return false; return document.activeElement === el; }})()"
1441 );
1442 let val = client.eval_js(&code).await?;
1443 Ok(val.as_bool().unwrap_or(false))
1444}
1445
1446async fn check_text_content(
1447 locator: &Locator,
1448 client: &mut VictauriClient,
1449) -> Result<String, TestError> {
1450 let el = locator.resolve_one(client).await?;
1451 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1452 let code = format!(
1453 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1454 if (!el) return \"\"; return el.textContent || \"\"; }})()"
1455 );
1456 let val = client.eval_js(&code).await?;
1457 Ok(value_to_string(&val))
1458}
1459
1460async fn check_input_value(
1461 locator: &Locator,
1462 client: &mut VictauriClient,
1463) -> Result<String, TestError> {
1464 let el = locator.resolve_one(client).await?;
1465 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1466 let code = format!(
1467 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1468 if (!el) return \"\"; return el.value || \"\"; }})()"
1469 );
1470 let val = client.eval_js(&code).await?;
1471 Ok(value_to_string(&val))
1472}
1473
1474async fn check_attribute(
1475 locator: &Locator,
1476 client: &mut VictauriClient,
1477 attr_name: &str,
1478) -> Result<Option<String>, TestError> {
1479 let el = locator.resolve_one(client).await?;
1480 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1481 let escaped = attr_name.replace('\\', "\\\\").replace('"', "\\\"");
1482 let code = format!(
1483 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1484 if (!el) return null; return el.getAttribute(\"{escaped}\"); }})()"
1485 );
1486 let val = client.eval_js(&code).await?;
1487 if val.is_null() {
1488 Ok(None)
1489 } else {
1490 Ok(Some(value_to_string(&val)))
1491 }
1492}
1493
1494async fn check_checked(locator: &Locator, client: &mut VictauriClient) -> Result<bool, TestError> {
1495 let el = locator.resolve_one(client).await?;
1496 let ref_str = serde_json::to_string(&el.ref_id).unwrap_or_else(|_| "\"\"".to_string());
1497 let code = format!(
1498 "(function() {{ var el = window.__VICTAURI__?.getRef({ref_str}); \
1499 if (!el) return false; return !!el.checked; }})()"
1500 );
1501 let val = client.eval_js(&code).await?;
1502 Ok(val.as_bool().unwrap_or(false))
1503}
1504
1505fn value_to_string(val: &Value) -> String {
1508 match val {
1509 Value::String(s) => s.clone(),
1510 Value::Null => String::new(),
1511 other => other.to_string(),
1512 }
1513}
1514
1515#[cfg(test)]
1518mod tests {
1519 use super::*;
1520 use serde_json::json;
1521
1522 #[test]
1523 fn locator_role_build_query() {
1524 let loc = Locator::role("button");
1525 let q = loc.build_query();
1526 assert_eq!(q["role"], json!("button"));
1527 assert_eq!(q["max_results"], json!(50));
1528 }
1529
1530 #[test]
1531 fn locator_text_build_query() {
1532 let loc = Locator::text("Submit");
1533 let q = loc.build_query();
1534 assert_eq!(q["text"], json!("Submit"));
1535 }
1536
1537 #[test]
1538 fn locator_test_id_build_query() {
1539 let loc = Locator::test_id("email");
1540 let q = loc.build_query();
1541 assert_eq!(q["test_id"], json!("email"));
1542 }
1543
1544 #[test]
1545 fn locator_css_build_query() {
1546 let loc = Locator::css(".card > h2");
1547 let q = loc.build_query();
1548 assert_eq!(q["css"], json!(".card > h2"));
1549 }
1550
1551 #[test]
1552 fn locator_with_name_filter() {
1553 let loc = Locator::role("button").name("Submit");
1554 let q = loc.build_query();
1555 assert_eq!(q["role"], json!("button"));
1556 assert_eq!(q["name"], json!("Submit"));
1557 }
1558
1559 #[test]
1560 fn locator_with_tag_filter() {
1561 let loc = Locator::text("Click me").and_tag("button");
1562 let q = loc.build_query();
1563 assert_eq!(q["text"], json!("Click me"));
1564 assert_eq!(q["tag"], json!("button"));
1565 }
1566
1567 #[test]
1568 fn locator_nth_selection() {
1569 let loc = Locator::css("li").nth(3);
1570 match loc.pick {
1571 Pick::Nth(n) => assert_eq!(n, 3),
1572 _ => panic!("expected Pick::Nth"),
1573 }
1574 }
1575
1576 #[test]
1577 fn locator_first_last() {
1578 let first = Locator::css("p").first();
1579 assert!(matches!(first.pick, Pick::First));
1580
1581 let last = Locator::css("p").last();
1582 assert!(matches!(last.pick, Pick::Last));
1583 }
1584
1585 #[test]
1586 fn parse_elements_array() {
1587 let data = json!([
1588 {"ref_id": "e1", "tag": "button", "role": "button", "name": "OK", "text": "OK",
1589 "visible": true, "enabled": true, "value": null,
1590 "bounds": {"x": 10.0, "y": 20.0, "width": 80.0, "height": 30.0}},
1591 {"ref_id": "e2", "tag": "input", "role": "textbox", "name": null, "text": "",
1592 "visible": true, "enabled": false, "value": "hello",
1593 "bounds": {"x": 0.0, "y": 0.0, "width": 200.0, "height": 24.0}}
1594 ]);
1595 let elements = Locator::parse_elements(&data);
1596 assert_eq!(elements.len(), 2);
1597 assert_eq!(elements[0].ref_id, "e1");
1598 assert_eq!(elements[0].tag, "button");
1599 assert!(elements[0].visible);
1600 assert!(elements[0].enabled);
1601 assert_eq!(elements[0].bounds.unwrap().width, 80.0);
1602 assert_eq!(elements[1].ref_id, "e2");
1603 assert!(!elements[1].enabled);
1604 assert_eq!(elements[1].value.as_deref(), Some("hello"));
1605 }
1606
1607 #[test]
1608 fn parse_elements_object() {
1609 let data = json!({
1610 "elements": [
1611 {"ref_id": "e5", "tag": "div", "visible": true, "enabled": true}
1612 ]
1613 });
1614 let elements = Locator::parse_elements(&data);
1615 assert_eq!(elements.len(), 1);
1616 assert_eq!(elements[0].ref_id, "e5");
1617 assert_eq!(elements[0].tag, "div");
1618 }
1619
1620 #[test]
1621 fn parse_elements_empty() {
1622 let data = json!([]);
1623 let elements = Locator::parse_elements(&data);
1624 assert!(elements.is_empty());
1625
1626 let data2 = json!({"elements": []});
1627 let elements2 = Locator::parse_elements(&data2);
1628 assert!(elements2.is_empty());
1629
1630 let data3 = json!(null);
1631 let elements3 = Locator::parse_elements(&data3);
1632 assert!(elements3.is_empty());
1633 }
1634
1635 #[test]
1636 fn apply_filters_exact_text() {
1637 let loc = Locator::role("button").and_text_exact("Submit");
1638 let elements = vec![
1639 make_match("e1", "button", Some("Submit Form")),
1640 make_match("e2", "button", Some("Submit")),
1641 make_match("e3", "button", Some("Cancel")),
1642 ];
1643 let filtered = loc.apply_filters(elements);
1644 assert_eq!(filtered.len(), 1);
1645 assert_eq!(filtered[0].ref_id, "e2");
1646 }
1647
1648 #[test]
1649 fn apply_filters_role() {
1650 let loc = Locator::text("OK").and_role("button");
1651 let elements = vec![
1652 LocatorMatch {
1653 ref_id: "e1".into(),
1654 tag: "button".into(),
1655 role: Some("button".into()),
1656 name: None,
1657 text: Some("OK".into()),
1658 visible: true,
1659 enabled: true,
1660 value: None,
1661 bounds: None,
1662 },
1663 LocatorMatch {
1664 ref_id: "e2".into(),
1665 tag: "span".into(),
1666 role: Some("generic".into()),
1667 name: None,
1668 text: Some("OK".into()),
1669 visible: true,
1670 enabled: true,
1671 value: None,
1672 bounds: None,
1673 },
1674 ];
1675 let filtered = loc.apply_filters(elements);
1676 assert_eq!(filtered.len(), 1);
1677 assert_eq!(filtered[0].ref_id, "e1");
1678 }
1679
1680 #[test]
1681 fn apply_filters_tag() {
1682 let loc = Locator::role("button").and_tag("a");
1683 let elements = vec![
1684 LocatorMatch {
1685 ref_id: "e1".into(),
1686 tag: "button".into(),
1687 role: Some("button".into()),
1688 name: None,
1689 text: None,
1690 visible: true,
1691 enabled: true,
1692 value: None,
1693 bounds: None,
1694 },
1695 LocatorMatch {
1696 ref_id: "e2".into(),
1697 tag: "a".into(),
1698 role: Some("button".into()),
1699 name: None,
1700 text: None,
1701 visible: true,
1702 enabled: true,
1703 value: None,
1704 bounds: None,
1705 },
1706 ];
1707 let filtered = loc.apply_filters(elements);
1708 assert_eq!(filtered.len(), 1);
1709 assert_eq!(filtered[0].ref_id, "e2");
1710 }
1711
1712 #[test]
1713 fn locator_display_role() {
1714 let loc = Locator::role("button").name("Submit");
1715 assert_eq!(loc.to_string(), "role(\"button\").name(\"Submit\")");
1716 }
1717
1718 #[test]
1719 fn locator_display_css_nth() {
1720 let loc = Locator::css(".card").nth(2);
1721 assert_eq!(loc.to_string(), "css(\".card\").nth(2)");
1722 }
1723
1724 #[test]
1725 fn locator_clone_and_modify() {
1726 let base = Locator::role("button");
1727 let submit = base.clone().name("Submit");
1728 let cancel = base.clone().name("Cancel");
1729
1730 assert_eq!(base.to_string(), "role(\"button\")");
1731 assert_eq!(submit.to_string(), "role(\"button\").name(\"Submit\")");
1732 assert_eq!(cancel.to_string(), "role(\"button\").name(\"Cancel\")");
1733 }
1734
1735 #[test]
1736 fn locator_send_sync() {
1737 fn assert_send_sync<T: Send + Sync>() {}
1738 assert_send_sync::<Locator>();
1739 assert_send_sync::<LocatorMatch>();
1740 assert_send_sync::<Bounds>();
1741 }
1742
1743 #[test]
1744 fn locator_label_build_query() {
1745 let loc = Locator::label("Email");
1746 let q = loc.build_query();
1747 assert_eq!(q["label"], json!("Email"));
1748 }
1749
1750 #[test]
1751 fn locator_placeholder_build_query() {
1752 let loc = Locator::placeholder("Enter email");
1753 let q = loc.build_query();
1754 assert_eq!(q["placeholder"], json!("Enter email"));
1755 }
1756
1757 #[test]
1758 fn locator_alt_text_build_query() {
1759 let loc = Locator::alt_text("Logo");
1760 let q = loc.build_query();
1761 assert_eq!(q["alt"], json!("Logo"));
1762 }
1763
1764 #[test]
1765 fn locator_title_build_query() {
1766 let loc = Locator::title("Close");
1767 let q = loc.build_query();
1768 assert_eq!(q["title_attr"], json!("Close"));
1769 }
1770
1771 #[test]
1772 fn locator_text_exact_build_query() {
1773 let loc = Locator::text_exact("Submit");
1774 let q = loc.build_query();
1775 assert_eq!(q["text"], json!("Submit"));
1776 assert_eq!(q["exact"], json!(true));
1777 }
1778
1779 #[test]
1780 fn locator_display_all_strategies() {
1781 assert_eq!(Locator::text("hi").to_string(), "text(\"hi\")");
1782 assert_eq!(Locator::text_exact("hi").to_string(), "text_exact(\"hi\")");
1783 assert_eq!(Locator::test_id("x").to_string(), "test_id(\"x\")");
1784 assert_eq!(Locator::label("E").to_string(), "label(\"E\")");
1785 assert_eq!(Locator::placeholder("p").to_string(), "placeholder(\"p\")");
1786 assert_eq!(Locator::alt_text("a").to_string(), "alt_text(\"a\")");
1787 assert_eq!(Locator::title("t").to_string(), "title(\"t\")");
1788 }
1789
1790 #[test]
1791 fn locator_display_has_attribute() {
1792 let loc = Locator::css("input")
1793 .and_has_attribute("required", None)
1794 .and_has_attribute("type", Some("email"));
1795 assert_eq!(
1796 loc.to_string(),
1797 "css(\"input\").and_has_attribute(\"required\", None).and_has_attribute(\"type\", Some(\"email\"))"
1798 );
1799 }
1800
1801 #[test]
1802 fn locator_display_last() {
1803 let loc = Locator::role("listitem").last();
1804 assert_eq!(loc.to_string(), "role(\"listitem\").last()");
1805 }
1806
1807 #[test]
1808 fn locator_display_and_text() {
1809 let loc = Locator::role("link").and_text("docs");
1810 assert_eq!(loc.to_string(), "role(\"link\").and_text(\"docs\")");
1811 }
1812
1813 #[test]
1814 fn parse_elements_skips_missing_ref_id() {
1815 let data = json!([
1816 {"tag": "div", "visible": true, "enabled": true},
1817 {"ref_id": "e1", "tag": "span", "visible": true, "enabled": true}
1818 ]);
1819 let elements = Locator::parse_elements(&data);
1820 assert_eq!(elements.len(), 1);
1821 assert_eq!(elements[0].ref_id, "e1");
1822 }
1823
1824 #[test]
1825 fn pick_one_first() {
1826 let loc = Locator::css("p").first();
1827 let elements = vec![
1828 make_match("e1", "p", Some("first")),
1829 make_match("e2", "p", Some("second")),
1830 ];
1831 let picked = loc.pick_one(elements).unwrap();
1832 assert_eq!(picked.ref_id, "e1");
1833 }
1834
1835 #[test]
1836 fn pick_one_last() {
1837 let loc = Locator::css("p").last();
1838 let elements = vec![
1839 make_match("e1", "p", Some("first")),
1840 make_match("e2", "p", Some("second")),
1841 ];
1842 let picked = loc.pick_one(elements).unwrap();
1843 assert_eq!(picked.ref_id, "e2");
1844 }
1845
1846 #[test]
1847 fn pick_one_nth() {
1848 let loc = Locator::css("p").nth(1);
1849 let elements = vec![
1850 make_match("e1", "p", Some("first")),
1851 make_match("e2", "p", Some("second")),
1852 make_match("e3", "p", Some("third")),
1853 ];
1854 let picked = loc.pick_one(elements).unwrap();
1855 assert_eq!(picked.ref_id, "e2");
1856 }
1857
1858 #[test]
1859 fn pick_one_empty_returns_error() {
1860 let loc = Locator::css("p");
1861 let result = loc.pick_one(Vec::new());
1862 assert!(result.is_err());
1863 let err = result.unwrap_err();
1864 assert!(matches!(err, TestError::ElementNotFound(_)));
1865 }
1866
1867 #[test]
1868 fn pick_one_nth_out_of_bounds() {
1869 let loc = Locator::css("p").nth(5);
1870 let elements = vec![make_match("e1", "p", None)];
1871 let result = loc.pick_one(elements);
1872 assert!(result.is_err());
1873 }
1874
1875 #[test]
1876 fn apply_filters_name_case_insensitive() {
1877 let loc = Locator::role("button").name("submit");
1878 let elements = vec![
1879 LocatorMatch {
1880 ref_id: "e1".into(),
1881 tag: "button".into(),
1882 role: Some("button".into()),
1883 name: Some("Submit Form".into()),
1884 text: Some("Submit".into()),
1885 visible: true,
1886 enabled: true,
1887 value: None,
1888 bounds: None,
1889 },
1890 LocatorMatch {
1891 ref_id: "e2".into(),
1892 tag: "button".into(),
1893 role: Some("button".into()),
1894 name: Some("Cancel".into()),
1895 text: Some("Cancel".into()),
1896 visible: true,
1897 enabled: true,
1898 value: None,
1899 bounds: None,
1900 },
1901 ];
1902 let filtered = loc.apply_filters(elements);
1903 assert_eq!(filtered.len(), 1);
1904 assert_eq!(filtered[0].ref_id, "e1");
1905 }
1906
1907 #[test]
1908 fn apply_filters_text_case_insensitive() {
1909 let loc = Locator::role("button").and_text("submit");
1910 let elements = vec![
1911 make_match("e1", "button", Some("Submit Form")),
1912 make_match("e2", "button", Some("Cancel")),
1913 ];
1914 let filtered = loc.apply_filters(elements);
1915 assert_eq!(filtered.len(), 1);
1916 assert_eq!(filtered[0].ref_id, "e1");
1917 }
1918
1919 #[test]
1920 fn text_exact_strategy_filters_client_side() {
1921 let loc = Locator::text_exact("OK");
1922 let elements = vec![
1923 make_match("e1", "span", Some("OK")),
1924 make_match("e2", "span", Some("OK button")),
1925 ];
1926 let filtered = loc.apply_filters(elements);
1927 assert_eq!(filtered.len(), 1);
1928 assert_eq!(filtered[0].ref_id, "e1");
1929 }
1930
1931 #[test]
1932 fn bounds_deserialize() {
1933 let json_str = r#"{"x":10.5,"y":20.0,"width":100.0,"height":50.5}"#;
1934 let bounds: Bounds = serde_json::from_str(json_str).unwrap();
1935 assert_eq!(bounds.x, 10.5);
1936 assert_eq!(bounds.height, 50.5);
1937 }
1938
1939 #[test]
1940 fn bounds_serialize_roundtrip() {
1941 let bounds = Bounds {
1942 x: 1.0,
1943 y: 2.0,
1944 width: 3.0,
1945 height: 4.0,
1946 };
1947 let json = serde_json::to_string(&bounds).unwrap();
1948 let deserialized: Bounds = serde_json::from_str(&json).unwrap();
1949 assert_eq!(bounds, deserialized);
1950 }
1951
1952 #[test]
1953 fn value_to_string_converts_types() {
1954 assert_eq!(value_to_string(&json!("hello")), "hello");
1955 assert_eq!(value_to_string(&json!(null)), "");
1956 assert_eq!(value_to_string(&json!(42)), "42");
1957 assert_eq!(value_to_string(&json!(true)), "true");
1958 }
1959
1960 fn make_match(ref_id: &str, tag: &str, text: Option<&str>) -> LocatorMatch {
1963 LocatorMatch {
1964 ref_id: ref_id.into(),
1965 tag: tag.into(),
1966 role: None,
1967 name: None,
1968 text: text.map(String::from),
1969 visible: true,
1970 enabled: true,
1971 value: None,
1972 bounds: None,
1973 }
1974 }
1975}