diff --git a/src/tab_utils.py b/src/tab_utils.py index a72012d..c936905 100644 --- a/src/tab_utils.py +++ b/src/tab_utils.py @@ -1,4 +1,4 @@ -from selenium.common.exceptions import WebDriverException, JavascriptException +from selenium.common.exceptions import WebDriverException, JavascriptException, NoSuchWindowException from selenium import webdriver GHOST_TAB_URLS = ( @@ -56,7 +56,7 @@ document.dispatchEvent(new Event('visibilitychange')); self.driver.close() print(f"[INFO] Closed tab with handle {handle} and URL {tab_url}.") - except WebDriverException: + except (WebDriverException, NoSuchWindowException): print(f"[WARNING] Could not close tab with handle {handle} and URL {tab_url}.") self.problematic_tabs.add(handle) pass diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..786d3dc --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,41 @@ +"""Minimal stand-ins for the selenium objects the selectors read. + +Only what the selectors actually call is implemented: text, DOM attributes, +visibility and find_element(s). Enough to drive the parsing and filtering +without a browser, and small enough to stay readable. +""" + +from selenium.common.exceptions import NoSuchElementException + + +class FakeElement: + def __init__(self, text="", attributes=None, children=None, displayed=True): + self.text = text + self.attributes = attributes or {} + # {(by, selector): [FakeElement, ...]} + self.children = children or {} + self.displayed = displayed + + def get_dom_attribute(self, name): + return self.attributes.get(name) + + def is_displayed(self): + return self.displayed + + def find_elements(self, by, selector): + return list(self.children.get((by, selector), [])) + + def find_element(self, by, selector): + found = self.find_elements(by, selector) + + if not found: + raise NoSuchElementException(f"no element for {by} {selector!r}") + + return found[0] + + +class FakeDriver(FakeElement): + """A driver behaves like an element for the lookups used here.""" + + def __init__(self, children=None): + super().__init__(children=children) diff --git a/tests/test_element_selectors.py b/tests/test_element_selectors.py new file mode 100644 index 0000000..5e5b8a3 --- /dev/null +++ b/tests/test_element_selectors.py @@ -0,0 +1,194 @@ +"""Tests for the parts of element_selectors that parse or choose. + +Every case here is a bug that reached a real run: a point value the parser could +not read, the wrong row of the breakdown panel, a container that looks right but +is empty, and a label that matches two different buttons. They need no browser, +so they run anywhere. + + python -m unittest discover -s tests +""" + +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from selenium.common.exceptions import NoSuchElementException +from selenium.webdriver.common.by import By + +import element_selectors +from fakes import FakeDriver, FakeElement + +STATUS_SELECTOR = "div.flex.w-full.items-center.gap-2" + + +def card(status_text=None, points_text=None): + """A misc card, optionally with a status block and a point value in it.""" + if status_text is None and points_text is None: + return FakeElement() + + status_children = {} + + if points_text is not None: + status_children[(By.TAG_NAME, "p")] = [FakeElement(text=points_text)] + + status = FakeElement(text=status_text or "", children=status_children) + + return FakeElement(children={(By.CSS_SELECTOR, STATUS_SELECTOR): [status]}) + + +def sidebar_driver(panel_text): + """A driver whose only section is the react-aria sidebar panel.""" + panel = FakeElement(text=panel_text, attributes={"id": "react-aria-42"}) + + return FakeDriver(children={(By.TAG_NAME, "section"): [panel]}) + + +def selectors_for(driver): + return element_selectors.ElementSelectionUtils(driver) + + +class CardPointValue(unittest.TestCase): + def test_reads_the_rendered_plus_prefix(self): + # The page renders "+10". int() happens to accept that, which hid the + # fragility until a variant added a unit. + self.assertEqual(selectors_for(FakeDriver()).get_card_point_value(card(points_text="+10")), 10) + + def test_reads_a_value_with_a_unit(self): + self.assertEqual(selectors_for(FakeDriver()).get_card_point_value(card(points_text="10 points")), 10) + + def test_is_zero_when_the_card_has_no_point_value(self): + # Promo cards carry a status block without a value. + self.assertEqual(selectors_for(FakeDriver()).get_card_point_value(card(status_text="")), 0) + + def test_is_zero_when_the_card_has_no_status_block(self): + self.assertEqual(selectors_for(FakeDriver()).get_card_point_value(card()), 0) + + +class CardCompletion(unittest.TestCase): + def test_completed_card(self): + self.assertTrue(selectors_for(FakeDriver()).card_is_complete(card(status_text="Completed"))) + + def test_open_card_showing_its_reward(self): + self.assertFalse(selectors_for(FakeDriver()).card_is_complete(card(status_text="+10"))) + + def test_card_without_a_status_block_is_not_complete(self): + self.assertFalse(selectors_for(FakeDriver()).card_is_complete(card())) + + +class SearchPointsRow(unittest.TestCase): + PANEL = "\n".join([ + "Points breakdown", + "Today's points", + "41", + "Bing search", + "6/15", + "Offers", + "20", + "This month", + "3,037", + "Lifetime", + "12,742", + ]) + + def test_reads_the_bing_search_row(self): + earned, maximum = selectors_for( + sidebar_driver(self.PANEL) + ).get_points_earned_from_searches_on_points_breakdown() + + self.assertEqual((earned, maximum), (6, 15)) + + def test_ignores_rows_that_are_not_the_search_row(self): + # Several rows share the same value class in the real panel, so a + # position based read returns whichever row happens to come first. + reordered = "\n".join([ + "Points breakdown", + "This month", + "3,037", + "Bing search", + "6/15", + ]) + + earned, maximum = selectors_for( + sidebar_driver(reordered) + ).get_points_earned_from_searches_on_points_breakdown() + + self.assertEqual((earned, maximum), (6, 15)) + + def test_handles_a_thousands_separator_in_the_fraction(self): + panel = "Bing search\n1,020/1,500" + + earned, maximum = selectors_for( + sidebar_driver(panel) + ).get_points_earned_from_searches_on_points_breakdown() + + self.assertEqual((earned, maximum), (1020, 1500)) + + def test_raises_when_the_panel_has_no_fraction(self): + with self.assertRaises(NoSuchElementException): + selectors_for( + sidebar_driver("Points breakdown\nLoading...") + ).get_points_earned_from_searches_on_points_breakdown() + + +class DuplicatedContainer(unittest.TestCase): + """Some sections are emitted twice for responsive layout.""" + + def _driver(self, visible_links, hidden_links): + def container(displayed, count): + links = [FakeElement(text=f"card {i}") for i in range(count)] + + return FakeElement( + displayed=displayed, + children={(By.TAG_NAME, "a"): links}, + ) + + return FakeDriver(children={ + (By.ID, "moreactivities"): [ + container(True, visible_links), + container(False, hidden_links), + ] + }) + + def test_refuses_the_hidden_copy_even_though_it_has_the_links(self): + # The visible copy is the empty one here. Handing back the hidden copy + # would produce links that cannot be clicked and whose text is empty, so + # this has to fail loudly rather than return them. + with self.assertRaises(NoSuchElementException): + selectors_for(self._driver(visible_links=0, hidden_links=7)).get_all_misc_cards() + + def test_uses_the_visible_copy_when_it_has_the_content(self): + cards = selectors_for(self._driver(visible_links=7, hidden_links=0)).get_all_misc_cards() + + self.assertEqual(len(cards), 7) + + +class DailySetOpener(unittest.TestCase): + """The opener label has to be distinguished from the level up entry.""" + + def _driver(self, labels): + buttons = [FakeElement(text=text) for text in labels] + + return FakeDriver(children={(By.TAG_NAME, "button"): buttons}) + + def test_matches_the_streak_button(self): + driver = self._driver([ + "Complete the Daily Set for 7 days in a row", + "Daily Set Streak\nDay 2 of 7 streak completed.", + ]) + + button = selectors_for(driver).get_open_daily_set_button() + + self.assertIn("Daily Set Streak", button.text) + + def test_does_not_match_the_level_up_entry_alone(self): + driver = self._driver(["Complete the Daily Set for 7 days in a row"]) + + # No streak button and no streaks section to fall back to. + with self.assertRaises(NoSuchElementException): + selectors_for(driver).get_open_daily_set_button() + + +if __name__ == "__main__": + unittest.main()