From 6af84687308a15627806948139518245f1b16729 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 22:48:18 -0600 Subject: [PATCH] feat: Page element maps with MRO inheritance and typed dispatch --- src/wabot/page.py | 81 +++++++++++++++++++++++++++++++++++ tests/unit/test_page.py | 95 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/wabot/page.py create mode 100644 tests/unit/test_page.py diff --git a/src/wabot/page.py b/src/wabot/page.py new file mode 100644 index 0000000..daef1c0 --- /dev/null +++ b/src/wabot/page.py @@ -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) diff --git a/tests/unit/test_page.py b/tests/unit/test_page.py new file mode 100644 index 0000000..58b8475 --- /dev/null +++ b/tests/unit/test_page.py @@ -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