"""Page Objects for blog web UI. This module provides Page Object classes for the blog application using SmartLocator for element interactions. """ from __future__ import annotations from typing import TYPE_CHECKING from pytfm.web.locator import SmartLocator as Loc if TYPE_CHECKING: from playwright.async_api import Page class HomePage: """Page Object for the blog home page. Provides methods for interacting with the posts list, pagination, and navigation elements. """ # Locators HEADER_LOGO = Loc.by_testid("nav-logo") PAGE_TITLE = Loc.by_testid("page-title-home") CREATE_POST_BTN = Loc.by_testid("btn-create-post-header") POST_LIST = Loc.by_testid("post-list") POST_CARD = Loc.by_css("[data-testid^='post-card-']") POST_TITLE = Loc.by_css("[data-testid^='post-title-']") POST_STATUS = Loc.by_css("[data-testid^='post-status-']") POST_AUTHOR = Loc.by_css("[data-testid^='post-author-']") POST_TAGS = Loc.by_css("[data-testid^='post-tags-']") READ_MORE_BTN = Loc.by_css("[data-testid^='btn-read-more-']") EMPTY_STATE = Loc.by_testid("empty-state") EMPTY_STATE_TITLE = Loc.by_testid("empty-state-title") CREATE_FIRST_POST_BTN = Loc.by_testid("btn-create-first-post") PAGINATION = Loc.by_testid("pagination") PAGINATION_PREV = Loc.by_testid("pagination-prev") PAGINATION_NEXT = Loc.by_testid("pagination-next") PAGINATION_CURRENT = Loc.by_testid("pagination-current") THEME_TOGGLE = Loc.by_testid("theme-toggle") def __init__(self, page: Page, base_url: str) -> None: """Initialize HomePage. Args: page: Playwright Page instance. base_url: Application base URL. """ self.page = page self.base_url = base_url.rstrip("/") self.url = f"{self.base_url}/web/" async def open(self) -> HomePage: """Navigate to home page. Returns: Self for method chaining. """ await self.page.goto(self.url) return self async def get_post_count(self) -> int: """Get number of posts displayed. Returns: Number of post cards on the page. """ return await self.POST_CARD.count(self.page) async def get_post_titles(self) -> list[str]: """Get list of post titles. Returns: List of title texts for all visible posts. """ titles = [] count = await self.get_post_count() for i in range(count): title_loc = self.POST_TITLE.nth(i) text = await title_loc.get_text(self.page) titles.append(text.strip()) return titles async def click_post_title(self, index: int = 0) -> None: """Click on post title by index. Args: index: Zero-based index of post to click. """ title_link = Loc.by_css(f"[data-testid='post-title-link-{index}']") await title_link.click_and_wait(self.page, navigation=True) async def click_read_more(self, index: int = 0) -> None: """Click 'Read more' button on post by index. Args: index: Zero-based index of post. """ btn = self.READ_MORE_BTN.nth(index) await btn.click_and_wait(self.page, navigation=True) async def click_create_post(self) -> None: """Click 'Write a Post' button.""" await self.CREATE_POST_BTN.click_and_wait( self.page, target_testid="form-post", navigation=True ) async def click_create_first_post(self) -> None: """Click 'Create your first post' button in empty state.""" await self.CREATE_FIRST_POST_BTN.click_and_wait( self.page, target_testid="form-post", navigation=True ) async def go_to_next_page(self) -> bool: """Click next page in pagination. Returns: True if navigation occurred, False if already on last page. """ next_btn = self.PAGINATION_NEXT.with_page(self.page) is_disabled = await next_btn.get_attribute("class") if is_disabled and "disabled" in is_disabled: return False await next_btn.click_and_wait(self.page, navigation=True) return True async def go_to_prev_page(self) -> bool: """Click previous page in pagination. Returns: True if navigation occurred, False if already on first page. """ prev_btn = self.PAGINATION_PREV.with_page(self.page) is_disabled = await prev_btn.get_attribute("class") if is_disabled and "disabled" in is_disabled: return False await prev_btn.click_and_wait(self.page, navigation=True) return True async def get_current_page_number(self) -> int: """Get current page number from pagination. Returns: Current page number as integer. """ text = await self.PAGINATION_CURRENT.get_text(self.page) return int(text.strip()) async def toggle_theme(self) -> None: """Toggle between light and dark theme.""" await self.THEME_TOGGLE.click(self.page) async def is_empty_state_visible(self) -> bool: """Check if empty state is displayed. Returns: True if no posts and empty state shown. """ return await self.EMPTY_STATE.is_visible(self.page) async def wait_for_posts_loaded(self) -> None: """Wait for posts to be loaded. Waits for either post list or empty state to appear. """ try: await self.POST_LIST.wait_for_visible(self.page, timeout=5000) except Exception: await self.EMPTY_STATE.wait_for_visible(self.page, timeout=2000) class PostDetailPage: """Page Object for individual post detail page. Provides methods for viewing post content and actions like edit, delete, publish/unpublish. """ # Locators POST_TITLE = Loc.by_testid("post-detail-title") POST_CONTENT = Loc.by_testid("post-detail-content") POST_AUTHOR = Loc.by_testid("post-detail-author") POST_DATE = Loc.by_testid("post-detail-date") POST_TAGS = Loc.by_testid("post-detail-tags") POST_STATUS = Loc.by_testid("post-detail-status") EDIT_BTN = Loc.by_testid("btn-edit-post") DELETE_BTN = Loc.by_testid("btn-delete-post") PUBLISH_BTN = Loc.by_testid("btn-publish-post") UNPUBLISH_BTN = Loc.by_testid("btn-unpublish-post") BACK_BTN = Loc.by_testid("btn-back-to-list") CONFIRM_DELETE_BTN = Loc.by_testid("btn-confirm-delete") CANCEL_DELETE_BTN = Loc.by_testid("btn-cancel-delete") FLASH_SUCCESS = Loc.by_testid("flash-success") FLASH_ERROR = Loc.by_testid("flash-error") def __init__(self, page: Page, base_url: str, slug: str = "") -> None: """Initialize PostDetailPage. Args: page: Playwright Page instance. base_url: Application base URL. slug: Post slug for direct navigation. """ self.page = page self.base_url = base_url.rstrip("/") self.slug = slug if slug: self.url = f"{self.base_url}/web/posts/{slug}" else: self.url = "" async def open(self, slug: str | None = None) -> PostDetailPage: """Navigate to post detail page. Args: slug: Post slug (uses instance slug if not provided). Returns: Self for method chaining. """ target_slug = slug or self.slug if not target_slug: raise ValueError("Slug must be provided") url = f"{self.base_url}/web/posts/{target_slug}" await self.page.goto(url) return self async def get_title(self) -> str: """Get post title text. Returns: Title text of the post. """ return await self.POST_TITLE.get_text(self.page) async def get_content(self) -> str: """Get post content text. Returns: Content text of the post. """ return await self.POST_CONTENT.get_text(self.page) async def get_author(self) -> str: """Get post author. Returns: Author identifier string. """ return await self.POST_AUTHOR.get_text(self.page) async def click_edit(self) -> None: """Click edit button.""" await self.EDIT_BTN.click_and_wait(self.page, target_testid="form-post", navigation=True) async def click_delete(self) -> None: """Click delete button (opens confirmation).""" await self.DELETE_BTN.click_and_wait(self.page, target_testid="modal-confirm-delete") async def confirm_delete(self) -> None: """Confirm deletion in modal.""" await self.CONFIRM_DELETE_BTN.click_and_wait( self.page, target_testid="flash-success", navigation=True ) async def cancel_delete(self) -> None: """Cancel deletion in modal.""" await self.CANCEL_DELETE_BTN.click(self.page) async def click_publish(self) -> None: """Click publish button.""" await self.PUBLISH_BTN.click_and_wait(self.page, target_testid="flash-success") async def click_unpublish(self) -> None: """Click unpublish button.""" await self.UNPUBLISH_BTN.click_and_wait(self.page, target_testid="flash-success") async def click_back(self) -> None: """Click back to list button.""" await self.BACK_BTN.click_and_wait(self.page, navigation=True) async def is_edit_visible(self) -> bool: """Check if edit button is visible. Returns: True if user can edit this post. """ return await self.EDIT_BTN.is_visible(self.page) async def is_delete_visible(self) -> bool: """Check if delete button is visible. Returns: True if user can delete this post. """ return await self.DELETE_BTN.is_visible(self.page) class PostFormPage: """Page Object for post creation/editing form. Provides methods for filling and submitting post forms. """ # Locators FORM = Loc.by_testid("form-post") TITLE_INPUT = Loc.by_testid("input-title") CONTENT_INPUT = Loc.by_testid("input-content") TAGS_INPUT = Loc.by_testid("input-tags") PUBLISHED_CHECKBOX = Loc.by_testid("checkbox-published") SUBMIT_BTN = Loc.by_testid("btn-submit-post") CANCEL_BTN = Loc.by_testid("btn-cancel") FORM_TITLE = Loc.by_testid("form-title") TITLE_ERROR = Loc.by_testid("error-title") CONTENT_ERROR = Loc.by_testid("error-content") def __init__(self, page: Page, base_url: str) -> None: """Initialize PostFormPage. Args: page: Playwright Page instance. base_url: Application base URL. """ self.page = page self.base_url = base_url.rstrip("/") async def open_create(self) -> PostFormPage: """Navigate to create post form. Returns: Self for method chaining. """ await self.page.goto(f"{self.base_url}/web/posts/new") return self async def open_edit(self, slug: str) -> PostFormPage: """Navigate to edit post form. Args: slug: Post slug to edit. Returns: Self for method chaining. """ await self.page.goto(f"{self.base_url}/web/posts/{slug}/edit") return self async def fill_title(self, title: str) -> PostFormPage: """Fill title field. Args: title: Post title. Returns: Self for method chaining. """ await self.TITLE_INPUT.fill(self.page, title) return self async def fill_content(self, content: str) -> PostFormPage: """Fill content field. Args: content: Post content. Returns: Self for method chaining. """ await self.CONTENT_INPUT.fill(self.page, content) return self async def fill_tags(self, tags: list[str]) -> PostFormPage: """Fill tags field. Args: tags: List of tag strings. Returns: Self for method chaining. """ tags_str = ", ".join(tags) await self.TAGS_INPUT.fill(self.page, tags_str) return self async def set_published(self, published: bool) -> PostFormPage: """Set published status checkbox. Args: published: True to check, False to uncheck. Returns: Self for method chaining. """ is_checked = await self.PUBLISHED_CHECKBOX.is_checked(self.page) if published != is_checked: if published: await self.PUBLISHED_CHECKBOX.check(self.page) else: await self.PUBLISHED_CHECKBOX.uncheck(self.page) return self async def submit(self) -> None: """Submit the form.""" await self.SUBMIT_BTN.click_and_wait( self.page, target_testid="post-detail-title", navigation=True ) async def click_cancel(self) -> None: """Click cancel button.""" await self.CANCEL_BTN.click_and_wait(self.page, navigation=True) async def get_title_error(self) -> str: """Get title validation error message. Returns: Error text or empty string if no error. """ if await self.TITLE_ERROR.is_visible(self.page): return await self.TITLE_ERROR.get_text(self.page) return "" async def get_content_error(self) -> str: """Get content validation error message. Returns: Error text or empty string if no error. """ if await self.CONTENT_ERROR.is_visible(self.page): return await self.CONTENT_ERROR.get_text(self.page) return "" async def get_form_title(self) -> str: """Get form title (Create Post / Edit Post). Returns: Form title text. """ return await self.FORM_TITLE.get_text(self.page) async def create_post( self, title: str, content: str, tags: list[str] | None = None, published: bool = False ) -> None: """Fill and submit new post form. Args: title: Post title. content: Post content. tags: Optional list of tags. published: Whether to publish immediately. """ await self.fill_title(title) await self.fill_content(content) if tags: await self.fill_tags(tags) await self.set_published(published) await self.submit() class NavigationComponent: """Component for site-wide navigation. Provides access to navigation elements present on all pages. """ # Locators NAV_LOGO = Loc.by_testid("nav-logo") NAV_HOME = Loc.by_testid("nav-link-home") NAV_POSTS = Loc.by_testid("nav-link-posts") NAV_ABOUT = Loc.by_testid("nav-link-about") NAV_PROFILE = Loc.by_testid("nav-link-profile") NAV_LOGIN = Loc.by_testid("nav-link-login") NAV_LOGOUT = Loc.by_testid("nav-link-logout") THEME_TOGGLE = Loc.by_testid("theme-toggle") USER_MENU = Loc.by_testid("user-menu") def __init__(self, page: Page) -> None: """Initialize NavigationComponent. Args: page: Playwright Page instance. """ self.page = page async def click_logo(self) -> None: """Click logo to go home.""" await self.NAV_LOGO.click_and_wait(self.page, navigation=True) async def click_home(self) -> None: """Click Home nav link.""" await self.NAV_HOME.click_and_wait(self.page, navigation=True) async def click_posts(self) -> None: """Click Posts nav link.""" await self.NAV_POSTS.click_and_wait(self.page, navigation=True) async def click_about(self) -> None: """Click About nav link.""" await self.NAV_ABOUT.click_and_wait(self.page, navigation=True) async def click_profile(self) -> None: """Click Profile nav link.""" await self.NAV_PROFILE.click_and_wait(self.page, navigation=True) async def click_login(self) -> None: """Click Login nav link.""" await self.NAV_LOGIN.click_and_wait(self.page, navigation=True) async def click_logout(self) -> None: """Click Logout nav link.""" await self.NAV_LOGOUT.click_and_wait(self.page, navigation=True) async def toggle_theme(self) -> None: """Toggle light/dark theme.""" await self.THEME_TOGGLE.click(self.page) async def is_logged_in(self) -> bool: """Check if user is logged in. Returns: True if logout link visible. """ return await self.NAV_LOGOUT.is_visible(self.page) async def is_logged_out(self) -> bool: """Check if user is logged out. Returns: True if login link visible. """ return await self.NAV_LOGIN.is_visible(self.page)