Skip to main content

jugar_probar/
page_object.rs

1//! Page Object Model Support (Feature 19)
2//!
3//! First-class support for the Page Object Model pattern in test automation.
4//!
5//! ## EXTREME TDD: Tests written FIRST per spec
6//!
7//! ## Toyota Way Application:
8//! - **Poka-Yoke**: Type-safe selectors prevent invalid queries at compile time
9//! - **Muda**: Reduce duplication by encapsulating page logic
10//! - **Genchi Genbutsu**: Page objects reflect actual page structure
11
12use crate::locator::{Locator, Selector};
13use std::collections::HashMap;
14
15/// Trait for page objects representing a page or component in the UI.
16///
17/// Implement this trait to create reusable page objects that encapsulate
18/// the structure and behavior of UI pages.
19///
20/// # Example
21///
22/// ```ignore
23/// struct LoginPage {
24///     username_input: Locator,
25///     password_input: Locator,
26///     submit_button: Locator,
27/// }
28///
29/// impl PageObject for LoginPage {
30///     fn url_pattern(&self) -> &str {
31///         "/login"
32///     }
33///
34///     fn is_loaded(&self) -> bool {
35///         // Check if page-specific elements are present
36///         true
37///     }
38/// }
39///
40/// impl LoginPage {
41///     pub fn new() -> Self {
42///         Self {
43///             username_input: Locator::new(Selector::css("input[name='username']")),
44///             password_input: Locator::new(Selector::css("input[name='password']")),
45///             submit_button: Locator::new(Selector::css("button[type='submit']")),
46///         }
47///     }
48///
49///     pub async fn login(&self, username: &str, password: &str) -> ProbarResult<()> {
50///         // Implementation
51///         Ok(())
52///     }
53/// }
54/// ```
55pub trait PageObject {
56    /// URL pattern that matches this page (e.g., "/login", "/users/*")
57    fn url_pattern(&self) -> &str;
58
59    /// Check if the page is fully loaded and ready for interaction
60    fn is_loaded(&self) -> bool {
61        true
62    }
63
64    /// Optional wait time for page load (in milliseconds)
65    fn load_timeout_ms(&self) -> u64 {
66        30000
67    }
68
69    /// Get the page name for logging/debugging
70    fn page_name(&self) -> &str {
71        std::any::type_name::<Self>()
72    }
73}
74
75/// Builder for creating page objects with locators
76#[derive(Debug, Clone)]
77pub struct PageObjectBuilder {
78    url_pattern: String,
79    locators: HashMap<String, Locator>,
80    load_timeout_ms: u64,
81}
82
83impl Default for PageObjectBuilder {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl PageObjectBuilder {
90    /// Create a new page object builder
91    #[must_use]
92    pub fn new() -> Self {
93        Self {
94            url_pattern: String::new(),
95            locators: HashMap::new(),
96            load_timeout_ms: 30000,
97        }
98    }
99
100    /// Set the URL pattern
101    #[must_use]
102    pub fn with_url_pattern(mut self, pattern: impl Into<String>) -> Self {
103        self.url_pattern = pattern.into();
104        self
105    }
106
107    /// Add a locator with a name
108    #[must_use]
109    pub fn with_locator(mut self, name: impl Into<String>, selector: Selector) -> Self {
110        let _ = self
111            .locators
112            .insert(name.into(), Locator::from_selector(selector));
113        self
114    }
115
116    /// Set the load timeout
117    #[must_use]
118    pub const fn with_load_timeout(mut self, timeout_ms: u64) -> Self {
119        self.load_timeout_ms = timeout_ms;
120        self
121    }
122
123    /// Build a simple page object
124    #[must_use]
125    pub fn build(self) -> SimplePageObject {
126        SimplePageObject {
127            url_pattern: self.url_pattern,
128            locators: self.locators,
129            load_timeout_ms: self.load_timeout_ms,
130        }
131    }
132}
133
134/// A simple generic page object implementation
135#[derive(Debug, Clone)]
136pub struct SimplePageObject {
137    url_pattern: String,
138    locators: HashMap<String, Locator>,
139    load_timeout_ms: u64,
140}
141
142impl SimplePageObject {
143    /// Create a new simple page object
144    #[must_use]
145    pub fn new(url_pattern: impl Into<String>) -> Self {
146        Self {
147            url_pattern: url_pattern.into(),
148            locators: HashMap::new(),
149            load_timeout_ms: 30000,
150        }
151    }
152
153    /// Get a locator by name
154    #[must_use]
155    pub fn locator(&self, name: &str) -> Option<&Locator> {
156        self.locators.get(name)
157    }
158
159    /// Add a locator
160    pub fn add_locator(&mut self, name: impl Into<String>, selector: Selector) {
161        let _ = self
162            .locators
163            .insert(name.into(), Locator::from_selector(selector));
164    }
165
166    /// Get all locator names
167    #[must_use]
168    pub fn locator_names(&self) -> Vec<&str> {
169        self.locators.keys().map(String::as_str).collect()
170    }
171}
172
173impl PageObject for SimplePageObject {
174    fn url_pattern(&self) -> &str {
175        &self.url_pattern
176    }
177
178    fn load_timeout_ms(&self) -> u64 {
179        self.load_timeout_ms
180    }
181}
182
183/// Page object registry for managing multiple pages
184#[derive(Debug, Default)]
185pub struct PageRegistry {
186    pages: HashMap<String, Box<dyn PageObjectInfo>>,
187}
188
189/// Trait for type-erased page object info
190pub trait PageObjectInfo: std::fmt::Debug + Send + Sync {
191    /// Get the URL pattern
192    fn url_pattern(&self) -> &str;
193
194    /// Get the page name
195    fn page_name(&self) -> &str;
196
197    /// Get the load timeout
198    fn load_timeout_ms(&self) -> u64;
199}
200
201impl<T: PageObject + std::fmt::Debug + Send + Sync + 'static> PageObjectInfo for T {
202    fn url_pattern(&self) -> &str {
203        PageObject::url_pattern(self)
204    }
205
206    fn page_name(&self) -> &str {
207        PageObject::page_name(self)
208    }
209
210    fn load_timeout_ms(&self) -> u64 {
211        PageObject::load_timeout_ms(self)
212    }
213}
214
215impl PageRegistry {
216    /// Create a new page registry
217    #[must_use]
218    pub fn new() -> Self {
219        Self::default()
220    }
221
222    /// Register a page object
223    pub fn register<T: PageObject + std::fmt::Debug + Send + Sync + 'static>(
224        &mut self,
225        name: impl Into<String>,
226        page: T,
227    ) {
228        let _ = self.pages.insert(name.into(), Box::new(page));
229    }
230
231    /// Get a page by name
232    #[must_use]
233    pub fn get(&self, name: &str) -> Option<&dyn PageObjectInfo> {
234        self.pages.get(name).map(|p| p.as_ref())
235    }
236
237    /// List all registered pages
238    #[must_use]
239    pub fn list(&self) -> Vec<&str> {
240        self.pages.keys().map(String::as_str).collect()
241    }
242
243    /// Get the number of registered pages
244    #[must_use]
245    pub fn count(&self) -> usize {
246        self.pages.len()
247    }
248}
249
250/// URL pattern matcher for page objects
251#[derive(Debug, Clone)]
252pub struct UrlMatcher {
253    pattern: String,
254    segments: Vec<UrlSegment>,
255}
256
257#[derive(Debug, Clone)]
258enum UrlSegment {
259    Literal(String),
260    Wildcard,
261    Parameter(String),
262}
263
264impl UrlMatcher {
265    /// Create a new URL matcher from a pattern
266    ///
267    /// Patterns support:
268    /// - Literal segments: `/login`
269    /// - Wildcards: `/users/*`
270    /// - Named parameters: `/users/:id`
271    #[must_use]
272    pub fn new(pattern: &str) -> Self {
273        let segments = pattern
274            .split('/')
275            .filter(|s| !s.is_empty())
276            .map(|s| {
277                if s == "*" {
278                    UrlSegment::Wildcard
279                } else if let Some(name) = s.strip_prefix(':') {
280                    UrlSegment::Parameter(name.to_string())
281                } else {
282                    UrlSegment::Literal(s.to_string())
283                }
284            })
285            .collect();
286
287        Self {
288            pattern: pattern.to_string(),
289            segments,
290        }
291    }
292
293    /// Check if a URL matches the pattern
294    #[must_use]
295    pub fn matches(&self, url: &str) -> bool {
296        let url_segments: Vec<&str> = url.split('/').filter(|s| !s.is_empty()).collect();
297
298        // URLs must have the same number of segments as the pattern
299        // (wildcards and parameters each consume exactly one segment)
300        if url_segments.len() != self.segments.len() {
301            return false;
302        }
303
304        for (i, segment) in self.segments.iter().enumerate() {
305            match segment {
306                UrlSegment::Literal(lit) => {
307                    if url_segments.get(i) != Some(&lit.as_str()) {
308                        return false;
309                    }
310                }
311                UrlSegment::Wildcard | UrlSegment::Parameter(_) => {
312                    // Matches anything (but requires a value to exist, enforced by length check)
313                }
314            }
315        }
316
317        true
318    }
319
320    /// Extract parameters from a URL
321    #[must_use]
322    pub fn extract_params(&self, url: &str) -> HashMap<String, String> {
323        let mut params = HashMap::new();
324        let url_segments: Vec<&str> = url.split('/').filter(|s| !s.is_empty()).collect();
325
326        for (i, segment) in self.segments.iter().enumerate() {
327            if let UrlSegment::Parameter(name) = segment {
328                if let Some(value) = url_segments.get(i) {
329                    let _ = params.insert(name.clone(), (*value).to_string());
330                }
331            }
332        }
333
334        params
335    }
336
337    /// Get the original pattern
338    #[must_use]
339    pub fn pattern(&self) -> &str {
340        &self.pattern
341    }
342}
343
344#[cfg(test)]
345#[allow(clippy::unwrap_used, clippy::expect_used)]
346mod tests {
347    use super::*;
348
349    mod page_object_builder_tests {
350        use super::*;
351
352        #[test]
353        fn test_builder_basic() {
354            let page = PageObjectBuilder::new()
355                .with_url_pattern("/login")
356                .with_load_timeout(5000)
357                .build();
358
359            assert_eq!(PageObject::url_pattern(&page), "/login");
360            assert_eq!(PageObject::load_timeout_ms(&page), 5000);
361        }
362
363        #[test]
364        fn test_builder_with_locators() {
365            let page = PageObjectBuilder::new()
366                .with_url_pattern("/login")
367                .with_locator("username", Selector::css("input[name='username']"))
368                .with_locator("password", Selector::css("input[name='password']"))
369                .build();
370
371            assert!(page.locator("username").is_some());
372            assert!(page.locator("password").is_some());
373            assert!(page.locator("nonexistent").is_none());
374        }
375
376        #[test]
377        fn test_default_builder() {
378            let builder = PageObjectBuilder::default();
379            let page = builder.build();
380            assert!(PageObject::url_pattern(&page).is_empty());
381        }
382    }
383
384    mod simple_page_object_tests {
385        use super::*;
386
387        #[test]
388        fn test_new() {
389            let page = SimplePageObject::new("/dashboard");
390            assert_eq!(PageObject::url_pattern(&page), "/dashboard");
391            assert_eq!(PageObject::load_timeout_ms(&page), 30000);
392        }
393
394        #[test]
395        fn test_add_locator() {
396            let mut page = SimplePageObject::new("/test");
397            page.add_locator("button", Selector::css("button"));
398
399            assert!(page.locator("button").is_some());
400            assert!(page.locator_names().contains(&"button"));
401        }
402
403        #[test]
404        fn test_is_loaded_default() {
405            let page = SimplePageObject::new("/test");
406            assert!(page.is_loaded());
407        }
408    }
409
410    mod page_registry_tests {
411        use super::*;
412
413        #[test]
414        fn test_new_registry() {
415            let registry = PageRegistry::new();
416            assert_eq!(registry.count(), 0);
417        }
418
419        #[test]
420        fn test_register_and_get() {
421            let mut registry = PageRegistry::new();
422            let page = SimplePageObject::new("/login");
423            registry.register("login", page);
424
425            assert_eq!(registry.count(), 1);
426            assert!(registry.get("login").is_some());
427            assert!(registry.get("nonexistent").is_none());
428        }
429
430        #[test]
431        fn test_list_pages() {
432            let mut registry = PageRegistry::new();
433            registry.register("login", SimplePageObject::new("/login"));
434            registry.register("home", SimplePageObject::new("/"));
435
436            let pages = registry.list();
437            assert_eq!(pages.len(), 2);
438            assert!(pages.contains(&"login"));
439            assert!(pages.contains(&"home"));
440        }
441    }
442
443    mod url_matcher_tests {
444        use super::*;
445
446        #[test]
447        fn test_literal_match() {
448            let matcher = UrlMatcher::new("/login");
449            assert!(matcher.matches("/login"));
450            assert!(!matcher.matches("/register"));
451            assert!(!matcher.matches("/login/extra"));
452        }
453
454        #[test]
455        fn test_wildcard_match() {
456            let matcher = UrlMatcher::new("/users/*");
457            assert!(matcher.matches("/users/123"));
458            assert!(matcher.matches("/users/abc"));
459            assert!(!matcher.matches("/users"));
460            assert!(!matcher.matches("/other/123"));
461        }
462
463        #[test]
464        fn test_parameter_match() {
465            let matcher = UrlMatcher::new("/users/:id");
466            assert!(matcher.matches("/users/123"));
467            assert!(matcher.matches("/users/abc"));
468            assert!(!matcher.matches("/users"));
469        }
470
471        #[test]
472        fn test_extract_params() {
473            let matcher = UrlMatcher::new("/users/:id/posts/:post_id");
474            let params = matcher.extract_params("/users/42/posts/100");
475
476            assert_eq!(params.get("id"), Some(&"42".to_string()));
477            assert_eq!(params.get("post_id"), Some(&"100".to_string()));
478        }
479
480        #[test]
481        fn test_complex_pattern() {
482            let matcher = UrlMatcher::new("/api/v1/users/:id");
483            assert!(matcher.matches("/api/v1/users/123"));
484            assert!(!matcher.matches("/api/v2/users/123"));
485        }
486
487        #[test]
488        fn test_pattern_getter() {
489            let matcher = UrlMatcher::new("/test/pattern");
490            assert_eq!(matcher.pattern(), "/test/pattern");
491        }
492    }
493
494    mod page_object_trait_tests {
495        use super::*;
496
497        #[derive(Debug)]
498        struct TestPage {
499            url: String,
500            loaded: bool,
501        }
502
503        impl PageObject for TestPage {
504            fn url_pattern(&self) -> &str {
505                &self.url
506            }
507
508            fn is_loaded(&self) -> bool {
509                self.loaded
510            }
511
512            fn load_timeout_ms(&self) -> u64 {
513                5000
514            }
515        }
516
517        #[test]
518        fn test_custom_page_object() {
519            let page = TestPage {
520                url: "/custom".to_string(),
521                loaded: true,
522            };
523
524            assert_eq!(PageObject::url_pattern(&page), "/custom");
525            assert!(PageObject::is_loaded(&page));
526            assert_eq!(PageObject::load_timeout_ms(&page), 5000);
527        }
528
529        #[test]
530        fn test_page_name() {
531            let page = SimplePageObject::new("/test");
532            // Should return the type name
533            assert!(PageObject::page_name(&page).contains("SimplePageObject"));
534        }
535    }
536}