mirror of
https://git.zavage.net/Zavage-Software/wabot.git
synced 2026-07-21 13:06:08 -06:00
feat: Page element maps with MRO inheritance and typed dispatch
This commit is contained in:
parent
9e825ffd80
commit
6af8468730
81
src/wabot/page.py
Normal file
81
src/wabot/page.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""Page: the base class consumers subclass to model a website page.
|
||||||
|
|
||||||
|
Pages declare a class-level ``elements`` map::
|
||||||
|
|
||||||
|
class Login(wabot.Page):
|
||||||
|
elements = {
|
||||||
|
"username": ("el", ("id", "username")),
|
||||||
|
"state": ("select", ("id", "state")),
|
||||||
|
"agree": ("checkbox", ("id", "agree")),
|
||||||
|
"rows": ("els", ("css selector", "tr.row")),
|
||||||
|
"special": (MyFieldClass, ("id", "special")),
|
||||||
|
}
|
||||||
|
|
||||||
|
Lookup walks the MRO, so subclasses inherit and override parent maps.
|
||||||
|
``page[key]`` returns a typed field wrapper — never a raw WebElement —
|
||||||
|
and a falsy ``NullField`` when the element cannot be found.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from selenium.common.exceptions import NoSuchElementException
|
||||||
|
|
||||||
|
from .fields import CheckField, NullField, SelectField, TextField
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("wabot")
|
||||||
|
|
||||||
|
ALERT_TIMEOUT = 3
|
||||||
|
PAGE_LOAD_TIMEOUT = 10
|
||||||
|
|
||||||
|
FIELD_TYPES = {"el": TextField, "select": SelectField, "checkbox": CheckField}
|
||||||
|
|
||||||
|
|
||||||
|
class Page:
|
||||||
|
elements: dict = {}
|
||||||
|
|
||||||
|
def __init__(self, browser):
|
||||||
|
self.browser = browser
|
||||||
|
self.driver = browser.driver
|
||||||
|
LOGGER.info("loaded page %s", type(self).__name__)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pacing(self):
|
||||||
|
return self.browser.pacing
|
||||||
|
|
||||||
|
def verify(self) -> bool:
|
||||||
|
"""Override to gate ``Browser.set_page`` (return False to refuse)."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def find_element_locators(self, key):
|
||||||
|
"""First match for ``key`` walking the class hierarchy (MRO order)."""
|
||||||
|
for cls in type(self).__mro__:
|
||||||
|
locators = getattr(cls, "elements", {}).get(key)
|
||||||
|
if locators:
|
||||||
|
return locators
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_proxy(self, key):
|
||||||
|
locators = self.find_element_locators(key)
|
||||||
|
if not locators:
|
||||||
|
LOGGER.warning("element not in page map: %s", key)
|
||||||
|
return NullField(name=key)
|
||||||
|
obj_type, accessors = locators[0], locators[1]
|
||||||
|
try:
|
||||||
|
if obj_type == "els":
|
||||||
|
by, value = accessors
|
||||||
|
return self.driver.find_elements(by=by, value=value)
|
||||||
|
field_cls = FIELD_TYPES.get(obj_type)
|
||||||
|
if field_cls is None and isinstance(obj_type, type):
|
||||||
|
field_cls = obj_type
|
||||||
|
if field_cls is not None:
|
||||||
|
return field_cls(page=self, accessors=accessors, name=key)
|
||||||
|
except NoSuchElementException:
|
||||||
|
LOGGER.warning("element %r not present on current page", key)
|
||||||
|
return NullField(name=key)
|
||||||
|
LOGGER.error("unknown element type for %r: %r", key, obj_type)
|
||||||
|
return NullField(name=key)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self.get_proxy(key)
|
||||||
95
tests/unit/test_page.py
Normal file
95
tests/unit/test_page.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from selenium.common.exceptions import NoSuchElementException
|
||||||
|
|
||||||
|
from wabot.fields import CheckField, NullField, SelectField, TextField
|
||||||
|
from wabot.pacing import NoPacing
|
||||||
|
from wabot.page import Page
|
||||||
|
|
||||||
|
|
||||||
|
class BasePage(Page):
|
||||||
|
elements = {
|
||||||
|
"username": ("el", ("id", "username")),
|
||||||
|
"state": ("select", ("id", "state")),
|
||||||
|
"agree": ("checkbox", ("id", "agree")),
|
||||||
|
"rows": ("els", ("css selector", "tr.row")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ChildPage(BasePage):
|
||||||
|
elements = {
|
||||||
|
"username": ("el", ("id", "user_name_v2")), # override
|
||||||
|
"extra": ("el", ("id", "extra")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_browser():
|
||||||
|
browser = MagicMock(name="browser")
|
||||||
|
browser.pacing = NoPacing()
|
||||||
|
return browser
|
||||||
|
|
||||||
|
|
||||||
|
class TestElementResolution:
|
||||||
|
def test_own_elements_found(self):
|
||||||
|
page = BasePage(make_browser())
|
||||||
|
assert page.find_element_locators("username") == ("el", ("id", "username"))
|
||||||
|
|
||||||
|
def test_mro_walk_inherits_parent_elements(self):
|
||||||
|
page = ChildPage(make_browser())
|
||||||
|
assert page.find_element_locators("state") == ("select", ("id", "state"))
|
||||||
|
assert page.find_element_locators("extra") == ("el", ("id", "extra"))
|
||||||
|
|
||||||
|
def test_child_overrides_parent(self):
|
||||||
|
page = ChildPage(make_browser())
|
||||||
|
assert page.find_element_locators("username") == ("el", ("id", "user_name_v2"))
|
||||||
|
|
||||||
|
def test_missing_key_returns_none(self):
|
||||||
|
assert BasePage(make_browser()).find_element_locators("nope") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetProxy:
|
||||||
|
def test_typed_dispatch(self):
|
||||||
|
page = BasePage(make_browser())
|
||||||
|
assert isinstance(page["username"], TextField)
|
||||||
|
assert isinstance(page["agree"], CheckField)
|
||||||
|
|
||||||
|
def test_select_dispatch(self, monkeypatch):
|
||||||
|
import wabot.fields as fields_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(fields_mod, "Select", MagicMock())
|
||||||
|
page = BasePage(make_browser())
|
||||||
|
assert isinstance(page["state"], SelectField)
|
||||||
|
|
||||||
|
def test_els_returns_find_elements_result(self):
|
||||||
|
browser = make_browser()
|
||||||
|
page = BasePage(browser)
|
||||||
|
result = page["rows"]
|
||||||
|
browser.driver.find_elements.assert_called_once_with(
|
||||||
|
by="css selector", value="tr.row"
|
||||||
|
)
|
||||||
|
assert result is browser.driver.find_elements.return_value
|
||||||
|
|
||||||
|
def test_unknown_key_returns_falsy_nullfield(self):
|
||||||
|
el = BasePage(make_browser())["nope"]
|
||||||
|
assert isinstance(el, NullField)
|
||||||
|
assert not el
|
||||||
|
|
||||||
|
def test_element_not_on_page_returns_nullfield(self):
|
||||||
|
browser = make_browser()
|
||||||
|
browser.driver.find_element.side_effect = NoSuchElementException("gone")
|
||||||
|
el = BasePage(browser)["username"]
|
||||||
|
assert isinstance(el, NullField)
|
||||||
|
|
||||||
|
def test_custom_field_class_dispatch(self):
|
||||||
|
class MyField(TextField):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CustomPage(Page):
|
||||||
|
elements = {"thing": (MyField, ("id", "thing"))}
|
||||||
|
|
||||||
|
assert isinstance(CustomPage(make_browser())["thing"], MyField)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerify:
|
||||||
|
def test_default_verify_is_true(self):
|
||||||
|
assert BasePage(make_browser()).verify() is True
|
||||||
Loading…
Reference in New Issue
Block a user