Files
blog.pyaqa.ru/tests/e2e/pages/__init__.py
Sergey Vanyushkin cf4982c0e5 test(e2e): add TC-E2E-003/004/005/007/008/009/010 — delete, pagination, errors, profile, theme
- test_post_deletion.py: user delete own, admin delete any, 403 for other's
- test_pagination.py: navigation across pages, boundary on last page
- test_errors.py: 404 nonexistent post, 404 for other user's draft
- test_post_lifecycle.py: draft-to-publish via edit flow
- test_post_ownership.py: user can edit own post
- test_profile_and_theme.py: profile page rendering, theme toggle with localStorage
- fix(web): remove infinite pagination for USER role (routes.py)
- fix(e2e): stabilize all publish() calls with expect_navigation
- fix(e2e): add _unique_title() to avoid slug collisions at scale
- docs: update FEATURE_POST_LIFECYCLE.md and TEST_MODEL.md coverage
2026-05-08 20:25:01 +03:00

278 lines
8.4 KiB
Python

"""Page Object Models for blog web UI.
Provides POM classes for home page, post form, and post detail pages.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from pytfm.web import BasePage, SmartLocator
if TYPE_CHECKING:
from playwright.sync_api import Page
class HomePage(BasePage):
"""Page object for the blog home/posts listing page.
Attributes:
path: URL path for the home page.
"""
path = "/web/"
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize the home page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
super().__init__(page, base_url)
self._create_post_btn = SmartLocator.by_testid("btn-create-post-header")
self._post_list = SmartLocator.by_testid("post-list")
self._empty_state = SmartLocator.by_testid("empty-state")
def create_post(self) -> None:
"""Click the 'Write a Post' button to navigate to the form."""
self._create_post_btn.click(self.page)
def has_post_with_title(self, title: str) -> bool:
"""Check if a post with the given title is present in the list.
Args:
title: Post title to search for.
Returns:
True if the post title is found on the page.
"""
safe_title = title.replace('"', '\\"')
selector = f'[data-testid^="post-title-link-"]:has-text("{safe_title}")'
return self.page.locator(selector).count() > 0
def has_no_post_with_title(self, title: str) -> bool:
"""Check that no post with the given title is present in the list.
Args:
title: Post title to search for.
Returns:
True if the post title is not found on the page.
"""
safe_title = title.replace('"', '\\"')
selector = f'[data-testid^="post-title-link-"]:has-text("{safe_title}")'
return self.page.locator(selector).count() == 0
def open_post(self, title: str) -> None:
"""Click on a post title link to open the detail page.
Args:
title: Post title to click.
"""
locator = self.page.locator("[data-testid^='post-title-link-']").filter(has_text=title)
locator.click()
def is_empty(self) -> bool:
"""Check if the posts list is empty.
Returns:
True if the empty state is visible.
"""
return self._empty_state.is_visible(self.page)
def count_posts(self) -> int:
"""Count the number of post cards on the page.
Returns:
Number of visible post cards.
"""
return self.page.locator('article[data-testid^="post-card-"]').count()
def get_current_page(self) -> int:
"""Get the current pagination page number.
Returns:
Current page number as integer.
"""
text = self.page.locator('[data-testid="pagination-current"]').text_content()
return int(text) if text else 1
def can_go_next(self) -> bool:
"""Check if the next page link is enabled.
Returns:
True if the next pagination control is a clickable link.
"""
tag = self.page.locator('[data-testid="pagination-next"]').evaluate(
"el => el.tagName.toLowerCase()"
)
return tag == "a"
def can_go_prev(self) -> bool:
"""Check if the previous page link is enabled.
Returns:
True if the previous pagination control is a clickable link.
"""
tag = self.page.locator('[data-testid="pagination-prev"]').evaluate(
"el => el.tagName.toLowerCase()"
)
return tag == "a"
def go_to_next_page(self) -> None:
"""Click the next page pagination link."""
self.page.locator('[data-testid="pagination-next"]').click()
def go_to_prev_page(self) -> None:
"""Click the previous page pagination link."""
self.page.locator('[data-testid="pagination-prev"]').click()
class PostFormPage(BasePage):
"""Page object for the new post / edit post form.
Attributes:
path: URL path for the new post form.
"""
path = "/web/posts/new"
def __init__(self, page: Page, base_url: str) -> None:
"""Initialize the post form page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
"""
super().__init__(page, base_url)
self._title_input = SmartLocator.by_testid("input-title")
self._content_input = SmartLocator.by_testid("textarea-content")
self._tags_input = SmartLocator.by_testid("input-tags")
self._publish_btn = SmartLocator.by_testid("btn-publish-post")
self._save_draft_btn = SmartLocator.by_testid("btn-save-draft")
def fill_form(self, title: str, content: str, tags: str) -> None:
"""Fill the post creation form.
Args:
title: Post title.
content: Post content (markdown).
tags: Comma-separated tags string.
"""
self._title_input.fill(self.page, title)
self._tags_input.fill(self.page, tags)
self.page.evaluate(
"(content) => {"
" const cm = document.querySelector('.CodeMirror');"
" if (cm && cm.CodeMirror) {"
" cm.CodeMirror.setValue(content);"
" }"
" const textarea = document.querySelector('[data-testid=\"textarea-content\"]');"
" if (textarea) textarea.value = content;"
"}",
content,
)
def publish(self) -> None:
"""Click the publish button to submit the form."""
self._publish_btn.click(self.page)
def save_draft(self) -> None:
"""Click the 'Save as Draft' button."""
self._save_draft_btn.click(self.page)
class PostDetailPage(BasePage):
"""Page object for the post detail page.
Attributes:
path_template: URL path template with {slug} placeholder.
"""
path_template = "/web/posts/{slug}"
def __init__(self, page: Page, base_url: str, slug: str) -> None:
"""Initialize the post detail page object.
Args:
page: Playwright Page instance.
base_url: Application base URL.
slug: Post URL slug.
"""
super().__init__(page, base_url)
self.slug = slug
self._title = SmartLocator.by_testid("post-detail-title")
self._status = SmartLocator.by_testid("post-detail-status")
self._content = SmartLocator.by_testid("post-detail-content")
self._edit_btn = SmartLocator.by_testid("btn-edit-post")
self._delete_btn = SmartLocator.by_testid("btn-delete-post")
@property
def url(self) -> str:
"""Return the full URL for this post detail page.
Returns:
Full post detail URL.
"""
return f"{self.base_url}{self.path_template.format(slug=self.slug)}"
def open(self) -> PostDetailPage:
"""Navigate to the post detail page.
Returns:
Self for method chaining.
"""
self.page.goto(self.url)
return self
def get_title(self) -> str:
"""Get the post title text.
Returns:
Post title string.
"""
return self._title.get_text(self.page)
def get_status(self) -> str:
"""Get the post status badge text.
Returns:
Status text ('Published' or 'Draft').
"""
return self._status.get_text(self.page)
def is_published(self) -> bool:
"""Check if the post status is 'Published'.
Returns:
True if status badge reads 'Published'.
"""
return self.get_status() == "Published"
def edit(self) -> None:
"""Click the edit button to navigate to the edit form."""
self._edit_btn.click(self.page)
def can_edit(self) -> bool:
"""Check if the edit button is visible.
Returns:
True if edit button is present.
"""
return self._edit_btn.is_visible(self.page)
def can_delete(self) -> bool:
"""Check if the delete button is visible.
Returns:
True if delete button is present.
"""
return self._delete_btn.is_visible(self.page)
def delete(self) -> None:
"""Click the delete button and accept the confirmation dialog."""
self.page.on("dialog", lambda dialog: dialog.accept())
self._delete_btn.click(self.page)