diff --git a/src/wabot/fields.py b/src/wabot/fields.py new file mode 100644 index 0000000..172a1ec --- /dev/null +++ b/src/wabot/fields.py @@ -0,0 +1,106 @@ +"""Typed wrappers around page elements. + +``page[key]`` returns one of these instead of a raw WebElement. Unknown +attribute access falls through to the underlying selenium element, so a +wrapper can be used anywhere an element can. ``NullField`` is the falsy +null-object returned for elements that could not be found, enabling +``if el:`` guards instead of exception handling. +""" + +from __future__ import annotations + +import logging +import time + +from selenium.webdriver.support.ui import Select + +LOGGER = logging.getLogger("wabot") + + +class PageObject: + """Base wrapper; subclasses add typed get/set behavior.""" + + def __init__(self, page, accessors=None, name=None): + self.page = page + self.accessors = accessors + self.name = name + self.el = self._locate() if accessors else None + + def _locate(self): + by, value = self.accessors + return self.page.driver.find_element(by=by, value=value) + + @property + def pacing(self): + return self.page.pacing + + def _pace(self, action: str) -> None: + delay = self.pacing.delay(action) + if delay: + time.sleep(delay) + + def __getattr__(self, name): + if name == "el": # guard: never recurse if __init__ didn't finish + raise AttributeError(name) + return getattr(self.el, name) + + def click(self): + return self.page.click(self.el) + + def click_and_go(self): + self._pace("navigate") + return self.page.click_and_go(self.el) + + +class TextField(PageObject): + def get_value(self): + return self.page.get_el_value(self.el) + + def set_value(self, value): + LOGGER.info("[%s] set_text(%r)", self.name, value) + self._pace("text") + return self.page.set_el_value(self.el, value) + + +class SelectField(PageObject): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.dropdown = Select(self.el) + + def get_value(self): + return self.page.get_select_value(self.dropdown) + + def set_value(self, value=None, text=None): + LOGGER.info("[%s] set_select(%r)", self.name, value if value is not None else text) + self._pace("select") + return self.page.set_select_value(self.dropdown, value=value, text=text) + + def select_by_index(self, index): + self._pace("select") + return self.dropdown.select_by_index(index) + + +class CheckField(PageObject): + def get_checked(self, ignore_disabled=False): + return self.page.get_checkbox_value(self.el, ignore_disabled) + + def set_checked(self, checked): + LOGGER.info("[%s] set_checked(%r)", self.name, checked) + self._pace("checkbox") + return self.page.set_checkbox(self.el, checked) + + +class NullField: + """Falsy placeholder for an element that was not found.""" + + def __init__(self, name=None): + # bypass __getattr__ tricks: plain attribute set + object.__setattr__(self, "name", name) + + def __bool__(self): + return False + + def __getattr__(self, attr): + raise AttributeError( + f"element {self.name!r} was not found on the page (attribute {attr!r})" + ) diff --git a/tests/unit/test_fields.py b/tests/unit/test_fields.py new file mode 100644 index 0000000..f757850 --- /dev/null +++ b/tests/unit/test_fields.py @@ -0,0 +1,87 @@ +from unittest.mock import MagicMock + +import pytest + +from wabot.fields import CheckField, NullField, PageObject, SelectField, TextField +from wabot.pacing import NoPacing + +ACCESSORS = ("id", "username") + + +@pytest.fixture +def page(): + page = MagicMock(name="page") + page.pacing = NoPacing() + return page + + +class TestPageObject: + def test_locates_element_via_page_driver(self, page): + obj = PageObject(page, accessors=ACCESSORS, name="username") + page.driver.find_element.assert_called_once_with(by="id", value="username") + assert obj.el is page.driver.find_element.return_value + + def test_unknown_attributes_fall_through_to_element(self, page): + obj = PageObject(page, accessors=ACCESSORS, name="username") + assert obj.tag_name is obj.el.tag_name + + def test_click_delegates_to_page(self, page): + obj = PageObject(page, accessors=ACCESSORS, name="username") + assert obj.click() is page.click.return_value + page.click.assert_called_once_with(obj.el) + + +class TestTextField: + def test_set_value_uses_page_helper(self, page): + field = TextField(page, accessors=ACCESSORS, name="username") + result = field.set_value("mathew") + page.set_el_value.assert_called_once_with(field.el, "mathew") + assert result is page.set_el_value.return_value + + def test_get_value_uses_page_helper(self, page): + field = TextField(page, accessors=ACCESSORS, name="username") + assert field.get_value() is page.get_el_value.return_value + + +class TestSelectField: + def test_wraps_element_in_selenium_select(self, page, monkeypatch): + import wabot.fields as fields_mod + + fake_select_cls = MagicMock(name="Select") + monkeypatch.setattr(fields_mod, "Select", fake_select_cls) + field = SelectField(page, accessors=("id", "state"), name="state") + fake_select_cls.assert_called_once_with(field.el) + assert field.dropdown is fake_select_cls.return_value + + def test_set_value_by_value_and_by_text(self, page, monkeypatch): + import wabot.fields as fields_mod + + monkeypatch.setattr(fields_mod, "Select", MagicMock()) + field = SelectField(page, accessors=("id", "state"), name="state") + field.set_value(value="CO") + page.set_select_value.assert_called_with(field.dropdown, value="CO", text=None) + field.set_value(text="Colorado") + page.set_select_value.assert_called_with(field.dropdown, value=None, text="Colorado") + + +class TestCheckField: + def test_set_and_get_checked(self, page): + field = CheckField(page, accessors=("id", "agree"), name="agree") + field.set_checked(True) + page.set_checkbox.assert_called_once_with(field.el, True) + field.get_checked() + page.get_checkbox_value.assert_called_once_with(field.el, False) + + +class TestNullField: + def test_is_falsy(self): + assert not NullField(name="missing") + + def test_attribute_access_raises_with_element_name(self): + with pytest.raises(AttributeError, match="missing"): + NullField(name="missing").click() + + def test_pattern_if_el_guards_work(self): + el = NullField(name="missing") + if el: + pytest.fail("NullField must be falsy")