diff --git a/FanGraphs/leaders.py b/FanGraphs/leaders.py index 4c28c9a..871f1e0 100644 --- a/FanGraphs/leaders.py +++ b/FanGraphs/leaders.py @@ -120,7 +120,7 @@ def __init__(self, browser="chromium"): self.page = self.__browser.new_page( accept_downloads=True ) - self.page.goto(self.address) + self.page.goto(self.address, timeout=0) self.soup = None self.__refresh_parser() @@ -433,6 +433,7 @@ def __init__(self, *, browser="chromium"): self.__browser = browser_ctx.launch() self.page = self.__browser.new_page() self.page.goto(self.address, timeout=0) + self.page.wait_for_selector(".fg-data-grid.undefined") self.soup = None self.__refresh_parser() @@ -497,21 +498,27 @@ def list_options(self, query: str): def current_option(self, query: str): """ Retrieves the option(s) which the filter query is currently set to. - *Note: Some dropdown- and split-class filter queries can be configured to multiple options. - Since this is the case, a list is returned, even though there may only be one option.* + + Most dropdown- and split-class filter queries can be configured to multiple options. + For those filter classes, a list is returned, while other filter classes return a string. + + - Selection-class: ``str`` + - Dropdown-class: ``list`` + - Split-class: ``list`` + - Switch-class: ``str`` :param query: The filter query being retrieved of its current option :return: The option(s) which the filter query is currently set to - :rtype: list + :rtype: str or list :raises FanGraphs.exceptions.InvalidFilterQuery: Argument ``query`` is invalid """ query = query.lower() - options = [] + option = [] if query in self.__selections: for sel in self.__selections[query]: elem = self.soup.select(sel)[0] if "isActive" in elem.get("class"): - options = [elem.getText()] + option = elem.getText() break elif query in self.__dropdowns: elems = self.soup.select( @@ -519,20 +526,20 @@ def current_option(self, query: str): ) for elem in elems: if "highlight-selection" in elem.get("class"): - options.append(elem.getText()) + option.append(elem.getText()) elif query in self.__splits: elems = self.soup.select( f"{self.__splits[query]} ul li" ) for elem in elems: if "highlight-selection" in elem.get("class"): - options.append(elem.getText()) + option.append(elem.getText()) elif query in self.__switches: elem = self.soup.select(self.__switches[query]) - options = ["True" if "isActive" in elem[0].get("class") else "False"] + option = "True" if "isActive" in elem[0].get("class") else "False" else: raise FanGraphs.exceptions.InvalidFilterQueryException(query) - return options + return option def configure(self, query: str, option: str, *, autoupdate=False): """ @@ -610,7 +617,7 @@ def __configure_split(self, query: str, option: str): elem = self.page.query_selector_all(f"{self.__splits[query]} ul li")[index] elem.click() - def __configure_switch(self, query, option): + def __configure_switch(self, query: str, option: str): """ Configures a switch-class filter query ``query`` to an option ``option``. @@ -654,8 +661,7 @@ def list_filter_groups(self): :return: Names of the groups of filter queries :rtype: list """ - selector = ".fgBin.splits-bin-controller div" - elems = self.soup.select(selector) + elems = self.soup.select(".fgBin.splits-bin-controller div") groups = [e.getText() for e in elems] return groups @@ -780,16 +786,14 @@ def __sortby(self, sortby, *, reverse=False): Sorts the data by the appropriate table header. :param sortby: The table header to sort the data by - :param reverse: If ``True``, the organizatino of the data will be reversed + :param reverse: If ``True``, the organization of the data will be reversed """ - selector = ".table-scroll thead tr th" - elems = self.soup.select(selector) + elems = self.soup.select(".table-scroll thead tr th") options = [e.getText() for e in elems] index = options.index(sortby) - option = self.page.query_selector_all(selector)[index] - option.click() + elems[index].click() if reverse: - option.click() + elems[index].click() def __write_table_headers(self, writer: csv.writer): """ @@ -797,8 +801,7 @@ def __write_table_headers(self, writer: csv.writer): :param writer: The ``csv.writer`` object """ - selector = ".table-scroll thead tr th" - elems = self.soup.select(selector) + elems = self.soup.select(".table-scroll thead tr th") headers = [e.getText() for e in elems] writer.writerow(headers) @@ -808,8 +811,7 @@ def __write_table_rows(self, writer: csv.writer): :param writer: The ``csv.writer`` object """ - selector = ".table-scroll tbody tr" - row_elems = self.soup.select(selector) + row_elems = self.soup.select(".table-scroll tbody tr") for row in row_elems: elems = row.select("td") items = [e.getText() for e in elems] @@ -895,6 +897,7 @@ def __init__(self, *, browser="chromium"): self.__browser = browser_ctx.launch() self.page = self.__browser.new_page() self.page.goto(self.address) + self.page.wait_for_selector(".fg-data-grid.undefined") self.soup = None self.__refresh_parsers() diff --git a/FanGraphs/tests/__init__.py b/FanGraphs/tests/__init__.py new file mode 100644 index 0000000..9fcacb0 --- /dev/null +++ b/FanGraphs/tests/__init__.py @@ -0,0 +1,2 @@ +#! python3 +# tests/__init__.py diff --git a/FanGraphs/tests/test_leaders.py b/FanGraphs/tests/test_leaders.py new file mode 100644 index 0000000..8abadeb --- /dev/null +++ b/FanGraphs/tests/test_leaders.py @@ -0,0 +1,601 @@ +#! python3 +# tests/test_leaders.py + +import bs4 +from playwright.sync_api import sync_playwright +import pytest +import requests + + +class TestMajorLeagueLeaderboards: + """ + Tests the attributes and methods in :py:class:`FanGraphs.leaders.MajorLeagueLeaderboards`. + The docstring in each test identifies the attribute(s)/method(s) being tested. + """ + + __selections = { + "group": "#LeaderBoard1_tsGroup", + "stat": "#LeaderBoard1_tsStats", + "position": "#LeaderBoard1_tsPosition", + "type": "#LeaderBoard1_tsType" + } + __dropdowns = { + "league": "#LeaderBoard1_rcbLeague_Input", + "team": "#LeaderBoard1_rcbTeam_Input", + "single_season": "#LeaderBoard1_rcbSeason_Input", + "split": "#LeaderBoard1_rcbMonth_Input", + "min_pa": "#LeaderBoard1_rcbMin_Input", + "season1": "#LeaderBoard1_rcbSeason1_Input", + "season2": "#LeaderBoard1_rcbSeason2_Input", + "age1": "#LeaderBoard1_rcbAge1_Input", + "age2": "#LeaderBoard1_rcbAge2_Input" + } + __dropdown_options = { + "league": "#LeaderBoard1_rcbLeague_DropDown", + "team": "#LeaderBoard1_rcbTeam_DropDown", + "single_season": "#LeaderBoard1_rcbSeason_DropDown", + "split": "#LeaderBoard1_rcbMonth_DropDown", + "min_pa": "#LeaderBoard1_rcbMin_DropDown", + "season1": "#LeaderBoard1_rcbSeason1_DropDown", + "season2": "#LeaderBoard1_rcbSeason2_DropDown", + "age1": "#LeaderBoard1_rcbAge1_DropDown", + "age2": "#LeaderBoard1_rcbAge2_DropDown" + } + __checkboxes = { + "split_teams": "#LeaderBoard1_cbTeams", + "active_roster": "#LeaderBoard1_cbActive", + "hof": "#LeaderBoard1_cbHOF", + "split_seasons": "#LeaderBoard1_cbSeason", + "rookies": "#LeaderBoard1_cbRookie" + } + __buttons = { + "season1": "#LeaderBoard1_btnMSeason", + "season2": "#LeaderBoard1_btnMSeason", + "age1": "#LeaderBoard1_cmdAge", + "age2": "#LeaderBoard1_cmdAge" + } + + address = "https://fangraphs.com/leaders.aspx" + + @classmethod + def setup_class(cls): + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(cls.address, timeout=0) + cls.soup = bs4.BeautifulSoup( + page.content(), features="lxml" + ) + browser.close() + + def test_address(self): + """ + Class attribute ``MajorLeagueLeaderboards.address``. + """ + res = requests.get(self.address) + assert res.status_code == 200 + + @pytest.mark.parametrize( + "selectors", + [__selections, __dropdown_options] + ) + def test_list_options(self, selectors: dict): + elem_count = { + "group": 3, "stat": 3, "position": 13, "type": 19, + "league": 3, "team": 31, "single_season": 151, "split": 67, + "min_pa": 60, "season1": 151, "season2": 151, "age1": 45, "age2": 45, + "split_teams": 2, "active_roster": 2, "hof": 2, "split_seasons": 2, + "rookies": 2 + } + for query, sel in selectors.items(): + elems = self.soup.select(f"{sel} li") + assert len(elems) == elem_count[query], query + assert all([isinstance(e.getText(), str) for e in elems]), query + + def test_current_option_selections(self): + """ + Instance method ``MajorLeagueLeaderboards.current_option``. + + Uses the selectors in: + + - ``MajorLeagueLeaderboards.__selections`` + """ + elem_text = { + "group": "Player Stats", "stat": "Batting", "position": "All", + "type": "Dashboard" + } + for query, sel in self.__selections.items(): + elem = self.soup.select(f"{sel} .rtsLink.rtsSelected") + assert len(elem) == 1, query + assert isinstance(elem[0].getText(), str), query + assert elem[0].getText() == elem_text[query] + + def test_current_option_dropdowns(self): + """ + Instance method ``MajorLeagueLeaderboards.current_option``. + + Uses the selectors in: + + - ``MajorLeagueLeaderboards.__dropdowns`` + """ + elem_value = { + "league": "All Leagues", "team": "All Teams", "single_season": "2020", + "split": "Full Season", "min_pa": "Qualified", "season1": "2020", + "season2": "2020", "age1": "14", "age2": "58" + } + for query, sel in self.__dropdowns.items(): + elem = self.soup.select(sel)[0] + assert elem.get("value") is not None, query + assert elem_value[query] == elem.get("value") + + @pytest.mark.parametrize( + "selectors", + [__selections, __dropdowns, __dropdown_options, + __checkboxes, __buttons] + ) + def test_configure(self, selectors: dict): + """ + Private instance method ``MajorLeagueLeaderboards.__configure_selection``. + Private instance method ``MajorLeagueLeaderboards.__configure_dropdown``. + Private instance method ``MajorLeagueLeaderboards.__configure_checkbox``. + Private instance method ``MajorLeagueLeaderboards.__click_button``. + + :param selectors: CSS Selectors + """ + for query, sel in selectors.items(): + elems = self.soup.select(sel) + assert len(elems) == 1, query + + def test_expand_sublevel(self): + """ + Statement in private instance method ``MajorLeagueLeaderboards.__configure_selection``. + """ + elems = self.soup.select("#LeaderBoard1_tsType a[href='#']") + assert len(elems) == 1 + + def test_export(self): + """ + Instance method ``MajorLeagueLeaderboards.export``. + """ + elems = self.soup.select("#LeaderBoard1_cmdCSV") + assert len(elems) == 1 + + +class TestSplitsLeaderboards: + """ + Tests the attributes and methods in :py:class:`FanGraphs.leaders.SplitsLeaderboards`. + The docstring in each test indentifies the attribute(s)/method(s) being tested. + """ + + __selections = { + "group": [ + ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(1)", + ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(2)", + ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(3)", + ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(4)" + ], + "stat": [ + ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(6)", + ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(7)" + ], + "type": [ + "#root-buttons-stats > div:nth-child(1)", + "#root-buttons-stats > div:nth-child(2)", + "#root-buttons-stats > div:nth-child(3)" + ] + } + __dropdowns = { + "time_filter": "#root-menu-time-filter > .fg-dropdown.splits.multi-choice", + "preset_range": "#root-menu-time-filter > .fg-dropdown.splits.single-choice", + "groupby": ".fg-dropdown.group-by" + } + __splits = { + "handedness": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(1)", + "home_away": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(2)", + "batted_ball": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(3)", + "situation": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(4)", + "count": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(5)", + "batting_order": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(1)", + "position": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(2)", + "inning": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(3)", + "leverage": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(4)", + "shifts": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(5)", + "team": ".fgBin:nth-child(3) > .fg-dropdown.splits.multi-choice:nth-child(1)", + "opponent": ".fgBin:nth-child(3) > .fg-dropdown.splits.multi-choice:nth-child(2)", + } + __quick_splits = { + "batting_home": ".quick-splits > div:nth-child(1) > div:nth-child(2) > .fgButton:nth-child(1)", + "batting_away": ".quick-splits > div:nth-child(1) > div:nth-child(2) > .fgButton:nth-child(2)", + "vs_lhp": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(1)", + "vs_lhp_home": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(2)", + "vs_lhp_away": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(3)", + "vs_lhp_as_lhh": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(4)", + "vs_lhp_as_rhh": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(5)", + "vs_rhp": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(1)", + "vs_rhp_home": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(2)", + "vs_rhp_away": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(3)", + "vs_rhp_as_lhh": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(4)", + "vs_rhp_as_rhh": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(5)", + "pitching_as_sp": ".quick-splits > div:nth-child(2) > div:nth-child(1) .fgButton:nth-child(1)", + "pitching_as_rp": ".quick-splits > div:nth-child(2) > div:nth-child(1) .fgButton:nth-child(2)", + "pitching_home": ".quick-splits > div:nth-child(2) > div:nth-child(2) > .fgButton:nth-child(1)", + "pitching_away": ".quick-splits > div:nth-child(2) > div:nth-child(2) > .fgButton:nth-child(2)", + "vs_lhh": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(1)", + "vs_lhh_home": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(2)", + "vs_lhh_away": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(3)", + "vs_lhh_as_rhp": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(4)", + "vs_lhh_as_lhp": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(5)", + "vs_rhh": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)", + "vs_rhh_home": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)", + "vs_rhh_away": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)", + "vs_rhh_as_rhp": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)", + "vs_rhh_as_lhp": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)" + } + __switches = { + "split_teams": "#stack-buttons > div:nth-child(2)", + "auto_pt": "#stack-buttons > div:nth-child(3)" + } + + address = "https://fangraphs.com/leaders/splits-leaderboards" + + @classmethod + def setup_class(cls): + """ + Initializes ``bs4.BeautifulSoup4`` object using ``playwright``. + """ + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(cls.address, timeout=0) + page.wait_for_selector(".fg-data-grid.undefined") + cls.soup = bs4.BeautifulSoup( + page.content(), features="lxml" + ) + browser.close() + + def test_address(self): + """ + Class attribute ``SplitsLeaderboards.address``. + """ + res = requests.get(self.address) + assert res.status_code == 200 + + def test_list_options_selections(self): + """ + Instance method ``SplitsLeaderboards.list_options``. + + Uses the selectors in: + + - ``SplitsLeaderboards.__selections`` + """ + elem_count = { + "group": 4, "stat": 2, "type": 3 + } + for query, sel_list in self.__selections.items(): + elems = [self.soup.select(s)[0] for s in sel_list] + assert len(elems) == elem_count[query] + assert all([e.getText() for e in elems]) + + @pytest.mark.parametrize( + "selectors", + [__dropdowns, __splits] + ) + def test_list_options(self, selectors: dict[str, str]): + """ + Instance method ``SplitsLeaderboards.list_options``. + + Uses the selectors in: + + - ``SplitsLeaderboards.__dropdowns`` + - ``SplitsLeaderboards.__splits`` + + :param selectors: CSS selectors + """ + elem_count = { + "time_filter": 10, "preset_range": 12, "groupby": 5, + "handedness": 4, "home_away": 2, "batted_ball": 15, + "situation": 7, "count": 11, "batting_order": 9, "position": 12, + "inning": 10, "leverage": 3, "shifts": 3, "team": 32, + "opponent": 32, + } + for query, sel in selectors.items(): + elems = self.soup.select(f"{sel} li") + assert len(elems) == elem_count[query] + + def test_current_option_selections(self): + """ + Instance method ``SplitsLeaderboards.current_option``. + + Uses the selectors in: + + - ``SplitsLeaderboards.__selections`` + """ + elem_text = { + "group": "Player", "stat": "Batting", "type": "Standard" + } + for query, sel_list in self.__selections.items(): + elems = [] + for sel in sel_list: + elem = self.soup.select(sel)[0] + assert elem.get("class") is not None + elems.append(elem) + active = ["isActive" in e.get("class") for e in elems] + assert active.count(True) == 1, query + text = [e.getText() for e in elems] + assert elem_text[query] in text + + @pytest.mark.parametrize( + "selectors", + [__dropdowns, __splits, __switches] + ) + def test_current_option(self, selectors: dict[str, str]): + """ + Instance method ``SplitsLeaderboards.current_option``. + + Uses the selectors in: + + - ``SplitsLeaderboards.__dropdowns`` + - ``SplitsLeaderboards.__splits`` + - ``SplitsLeaderboards.__switches`` + + :param selectors: CSS selectors + """ + for query, sel in selectors.items(): + elems = self.soup.select(f"{sel} li") + for elem in elems: + assert elem.get("class") is not None + + def test_configure_selection(self): + """ + Private instance method ``SplitsLeaderboards.__configure_selection``. + """ + for query, sel_list in self.__selections.items(): + for sel in sel_list: + elems = self.soup.select(sel) + assert len(elems) == 1, query + + @pytest.mark.parametrize( + "selectors", + [__dropdowns, __splits, __switches] + ) + def test_configure(self, selectors: dict[str, str]): + """ + Private instance method ``SplitsLeaderboards.__configure_dropdown``. + Private instance method ``SplitsLeaderboards.__configure_split``. + Private instance method ``SplitsLeaderboards.__configure_switch``. + + :param selectors: CSS Selectors + """ + for query, sel in selectors.items(): + elems = self.soup.select(sel) + assert len(elems) == 1, query + + def test_update(self): + """ + Instance method ``SplitsLeaderboards.update``. + """ + elems = self.soup.select("#button-update") + assert len(elems) == 0 + + def test_list_filter_groups(self): + """ + Instance method ``SplitsLeaderboards.list_filter_groups``. + """ + elems = self.soup.select(".fgBin.splits-bin-controller div") + assert len(elems) == 4 + options = ["Quick Splits", "Splits", "Filters", "Show All"] + assert [e.getText() for e in elems] == options + + def test_configure_filter_group(self): + """ + Instance method ``SplitsLeaderboards.configure_filter_group``. + """ + groups = ["Quick Splits", "Splits", "Filters", "Show All"] + elems = self.soup.select(".fgBin.splits-bin-controller div") + assert len(elems) == 4 + assert [e.getText() for e in elems] == groups + + def test_reset_filters(self): + """ + Instance method ``SplitsLeaderboards.reset_filters``. + """ + elems = self.soup.select("#stack-buttons .fgButton.small:nth-last-child(1)") + assert len(elems) == 1 + + def test_configure_quick_split(self): + """ + Instance method ``SplitsLeaderboards.configure_quick_split``. + """ + for qsplit, sel in self.__quick_splits.items(): + elems = self.soup.select(sel) + assert len(elems) == 1, qsplit + + def test_expand_table(self): + """ + Private instance method ``SplitsLeaderboards.__expand_table``. + """ + elems = self.soup.select(".table-page-control:nth-child(3) select") + assert len(elems) == 1 + options = ["30", "50", "100", "200", "Infinity"] + assert [e.getText() for e in elems[0].select("option")] == options + + def test_sortby(self): + """ + Private instance method ``SplitsLeaderboards.__sortby``. + """ + elems = self.soup.select(".table-scroll thead tr th") + assert len(elems) == 24 + + def test_write_table_headers(self): + """ + Private instance method ``SplitsLeaderboards.__write_table_headers``. + """ + elems = self.soup.select(".table-scroll thead tr th") + assert len(elems) == 24 + + def test_write_table_rows(self): + """ + Private instance method ``SplitsLeaderboards.__write_table_rows``. + """ + elems = self.soup.select(".table-scroll tbody tr") + assert len(elems) == 30 + for elem in elems: + assert len(elem.select("td")) == 24 + + +class TestSeasonStatGrid: + """ + Tests the attributes and methods in :py:class:`FanGraphs.leaders.SeasonStatGrid`. + The docstring in each test indentifies the attribute(s)/method(s) being tested. + """ + __selections = { + "stat": [ + "div[class*='fgButton button-green']:nth-child(1)", + "div[class*='fgButton button-green']:nth-child(2)" + ], + "type": [ + "div[class*='fgButton button-green']:nth-child(4)", + "div[class*='fgButton button-green']:nth-child(5)", + "div[class*='fgButton button-green']:nth-child(6)" + ] + } + __dropdowns = { + "start_season": ".row-season > div:nth-child(2)", + "end_season": ".row-season > div:nth-child(4)", + "popular": ".season-grid-controls-dropdown-row-stats > div:nth-child(1)", + "standard": ".season-grid-controls-dropdown-row-stats > div:nth-child(2)", + "advanced": ".season-grid-controls-dropdown-row-stats > div:nth-child(3)", + "statcast": ".season-grid-controls-dropdown-row-stats > div:nth-child(4)", + "batted_ball": ".season-grid-controls-dropdown-row-stats > div:nth-child(5)", + "win_probability": ".season-grid-controls-dropdown-row-stats > div:nth-child(6)", + "pitch_type": ".season-grid-controls-dropdown-row-stats > div:nth-child(7)", + "plate_discipline": ".season-grid-controls-dropdown-row-stats > div:nth-child(8)", + "value": ".season-grid-controls-dropdown-row-stats > div:nth-child(9)" + } + address = "https://fangraphs.com/leaders/season-stat-grid" + + @classmethod + def setup_class(cls): + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(cls.address, timeout=0) + page.wait_for_selector(".fg-data-grid.undefined") + cls.soup = bs4.BeautifulSoup( + page.content(), features="lxml" + ) + browser.close() + + def test_address(self): + """ + Class attribute ``SeasonStatGrid.address`` + """ + res = requests.get(self.address) + assert res.status_code == 200 + + def test_list_options_selections(self): + """ + Instance method ``SeasonStatGrid.list_options``. + + Uses the following class attributes: + + - ``SeasonStatGrid.__selections`` + """ + elem_count = { + "stat": 2, "group": 3, "type": 3 + } + for query, sel_list in self.__selections.items(): + elems = [self.soup.select(s)[0] for s in sel_list] + assert len(elems) == elem_count[query] + assert all([e.getText() for e in elems]) + + def test_list_options_dropdowns(self): + """ + Instance method ``SeasonStatGrid.list_options``. + + Uses the following class attributes: + + - ``SeasonStatGrid.__dropdowns`` + """ + elem_count = { + "start_season": 71, "end_season": 71, "popular": 6, + "standard": 20, "advanced": 17, "statcast": 8, "batted_ball": 24, + "win_probability": 10, "pitch_type": 25, "plate_discipline": 25, + "value": 11 + } + for query, sel in self.__dropdowns.items(): + elems = self.soup.select(f"{sel} li") + assert len(elems) == elem_count[query], query + assert all([e.getText() for e in elems]) + + def test_current_option_selections(self): + """ + Instance method ``SeasonStatGrid.current_option``. + + Tests the following class attributes: + + - ``SeasonStatGrid.__selections`` + """ + selector = "div[class='fgButton button-green active isActive']" + elems = self.soup.select(selector) + assert len(elems) == 2 + + def test_current_options_dropdowns(self): + """ + Instance method ``SeasonStatGrid.current_option``. + + Uses the following class attributes: + + - ``SeasonStatGrid.__dropdowns`` + """ + for query, sel in self.__dropdowns.items(): + elems = self.soup.select( + f"{sel} li[class$='highlight-selection']" + ) + if query in ["start_season", "end_season", "popular", "value"]: + assert len(elems) == 1, query + assert elems[0].getText() is not None + else: + assert len(elems) == 0, query + + def test_configure_selection(self): + """ + Private instance method ``SeasonStatGrid.__configure_selection``. + """ + for query, sel_list in self.__selections.items(): + for sel in sel_list: + elems = self.soup.select(sel) + assert len(elems) == 1, query + + def test_configure_dropdown(self): + """ + Private instance method ``SeasonStatGrid.__configure_dropdown``. + """ + for query, sel in self.__dropdowns.items(): + elems = self.soup.select(sel) + assert len(elems) == 1, query + + def test_expand_table(self): + """ + Private instance method ``SeasonStatGrid.__expand_table`` + """ + elems = self.soup.select(".table-page-control:nth-child(3) select") + assert len(elems) == 1 + options = ["30", "50", "100", "200", "Infinity"] + assert [e.getText() for e in elems[0].select("option")] == options + + def test_write_table_headers(self): + """ + Private instance method ``SeasonStatGrid.__write_table_headers``. + """ + elems = self.soup.select(".table-scroll thead tr th") + assert len(elems) == 12 + + def test_write_table_rows(self): + """ + Private instance method ``SeasonStatGrid.__write_table_rows``. + """ + elems = self.soup.select(".table-scroll tbody tr") + assert len(elems) == 30 + for elem in elems: + assert len(elem.select("td")) == 12 diff --git a/functional_tests.py b/functional_tests.py deleted file mode 100644 index 6f9951f..0000000 --- a/functional_tests.py +++ /dev/null @@ -1,403 +0,0 @@ -#! python3 -# functional_tests.py - -import csv -import os -import random -import requests -import unittest - -from FanGraphs import exceptions -from FanGraphs import leaders - - -unittest.TestLoader.sortTestMethodsUsing = None - - -class TestExceptions(unittest.TestCase): - - def test_major_league_leaderboards(self): - parser = leaders.MajorLeagueLeaderboards() - - with self.assertRaises( - exceptions.InvalidFilterQueryException - ): - parser.list_options("nonexistent query") - - with self.assertRaises( - exceptions.InvalidFilterQueryException - ): - parser.current_option("nonexistent query") - - with self.assertRaises( - exceptions.InvalidFilterQueryException - ): - parser.configure("nonexistent query", "nonexistent option") - - parser.quit() - - def test_season_stat_grid(self): - parser = leaders.SeasonStatGrid() - - with self.assertRaises( - exceptions.InvalidFilterQueryException - ): - parser.list_options("nonexistent query") - - with self.assertRaises( - exceptions.InvalidFilterQueryException - ): - parser.current_option("nonexistent query") - - with self.assertRaises( - exceptions.InvalidFilterQueryException - ): - parser.configure("nonexistent query", "nonexistent option") - - with self.assertRaises( - exceptions.InvalidFilterOptionException - ): - parser.configure("Stat", "nonexistent option") - - parser.quit() - - -class TestMajorLeagueLeaderboards(unittest.TestCase): - - parser = leaders.MajorLeagueLeaderboards() - - @classmethod - def setUpClass(cls): - cls.base_url = cls.parser.page.url - - @classmethod - def tearDownClass(cls): - cls.parser.quit() - for file in os.listdir("out"): - os.remove(os.path.join("out", file)) - os.rmdir("out") - os.system("taskkill /F /IM firefox.exe") - - def test_init(self): - self.assertEqual( - requests.get(self.parser.address).status_code, 200 - ) - self.assertTrue(self.parser.page) - self.assertTrue(self.parser.soup) - - def test_list_queries(self): - queries = self.parser.list_queries() - self.assertIsInstance(queries, list) - self.assertTrue( - all([isinstance(q, str) for q in queries]) - ) - - def test_list_options(self): - query_count = { - "group": 3, "stat": 3, "position": 13, - "league": 3, "team": 31, "single_season": 151, "split": 67, - "min_pa": 60, "season1": 151, "season2": 151, "age1": 45, "age2": 45, - "split_teams": 2, "active_roster": 2, "hof": 2, "split_seasons": 2, - "rookies": 2 - } - for query in query_count: - options = self.parser.list_options(query) - self.assertIsInstance(options, list) - self.assertTrue( - all([isinstance(o, str) for o in options]) - or all([isinstance(o, bool) for o in options]) - ) - self.assertEqual( - len(options), - query_count[query], - (query, len(options)) - ) - - def test_current_option(self): - query_options = { - "group": "Player Stats", "stat": "Batting", "position": "All", - "league": "All Leagues", "team": "All Teams", "single_season": "2020", - "split": "Full Season", "min_pa": "Qualified", "season1": "2020", - "season2": "2020", "age1": "14", "age2": "58", "split_teams": "False", - "active_roster": "False", "hof": "False", "split_seasons": "False", - "rookies": "False" - } - for query in query_options: - option = self.parser.current_option(query) - self.assertEqual( - option, - query_options[query], - (query, option) - ) - - def test_configure(self): - queries = [ - "group", "stat", "position", "league", "team", "single_season", - "split", "min_pa", "season1", "season2", "age1", "age2", - "split_teams", "active_roster", "hof", "split_seasons", "rookies" - ] - for query in queries: - option = random.choice(self.parser.list_options(query)) - self.parser.configure(query, option) - if query not in ["season1", "season2", "age1", "age2"]: - current = self.parser.current_option(query) - self.assertEqual( - option, - current, - (query, option, current) - ) - self.parser.reset() - - def test_reset(self): - self.parser.page.goto("https://google.com") - self.parser.reset() - self.assertEqual( - self.parser.page.url, - self.base_url - ) - - def test_export(self): - self.parser.export("test.csv") - self.assertTrue( - os.path.exists(os.path.join("out", "test.csv")) - ) - - -class TestSplitsLeaderboards(unittest.TestCase): - - parser = leaders.SplitsLeaderboards() - - @classmethod - def setUpClass(cls): - cls.base_url = cls.parser.page.url - - @classmethod - def tearDownClass(cls): - cls.parser.quit() - for file in os.listdir("out"): - os.remove(os.path.join("out", file)) - os.rmdir("out") - os.system("taskkill /F /IM firefox.exe") - - def test_00(self): - """ - SplitsLeaderboards.__init__ - """ - self.assertEqual( - requests.get(self.parser.address).status_code, 200 - ) - self.assertTrue(os.path.exists("out")) - self.assertTrue(self.parser.page) - self.assertTrue(self.parser.soup) - - def test_01(self): - """ - SplitsLeaderboards.list_queries - """ - # SeasonStatGrid.list_queries - self.assertEqual( - len(self.parser.list_queries()), 20 - ) - - def test_02(self): - """ - SplitsLeaderboards.list_filter_groups - """ - groups = self.parser.list_filter_groups() - self.assertEqual( - len(groups), 4 - ) - self.assertEqual( - groups, ["Quick Splits", "Splits", "Filters", "Show All"] - ) - - def test_03(self): - """ - SplitsLeaderboards.list_options - """ - option_count = { - "group": 4, "stat": 2, "type": 3, - "time_filter": 10, "preset_range": 12, "groupby": 5, - "handedness": 4, "home_away": 2, "batted_ball": 15, - "situation": 7, "count": 11, "batting_order": 9, "position": 12, - "inning": 10, "leverage": 3, "shifts": 3, "team": 32, - "opponent": 32, - "split_teams": 2, "auto_pt": 2 - } - for query in option_count: - self.assertEqual( - len(self.parser.list_options(query)), option_count[query], - query - ) - - def test_04(self): - """ - SplitsLeaderboards.current_option - """ - current_options = { - "group": ["Player"], "stat": ["Batting"], "type": ["Standard"], - "time_filter": [], "preset_range": [], "groupby": ["Season"], - "handedness": [], "home_away": [], "batted_ball": [], - "situation": [], "count": [], "batting_order": [], "position": [], - "inning": [], "leverage": [], "shifts": [], "team": [], - "opponent": [], - "split_teams": ["False"], "auto_pt": ["False"] - } - for query in current_options: - self.assertEqual( - self.parser.current_option(query), current_options[query], - query - ) - - def test_05(self): - """ - SplitsLeaderboards.configure - """ - queries = self.parser.list_queries() - for query in queries: - if query in ["type"]: - continue - option = self.parser.list_options(query)[-1] - self.parser.configure(query, option, autoupdate=True) - self.assertIn( - option, self.parser.current_option(query), - query - ) - self.parser.reset() - - def test_06(self): - """ - SplitsLeaderboards.list_quick_splits - """ - quick_splits = [ - 'batting_home', 'batting_away', 'vs_lhp', 'vs_lhp_home', - 'vs_lhp_away', 'vs_lhp_as_lhh', 'vs_lhp_as_rhh', 'vs_rhp', - 'vs_rhp_home', 'vs_rhp_away', 'vs_rhp_as_lhh', 'vs_rhp_as_rhh', - 'pitching_as_sp', 'pitching_as_rp', 'pitching_home', - 'pitching_away', 'vs_lhh', 'vs_lhh_home', 'vs_lhh_away', - 'vs_lhh_as_rhp', 'vs_lhh_as_lhp', 'vs_rhh', 'vs_rhh_home', - 'vs_rhh_away', 'vs_rhh_as_rhp', 'vs_rhh_as_lhp' - ] - self.assertEqual( - self.parser.list_quick_splits(), quick_splits - ) - - def test_07(self): - """ - SplitsLeaderboards.configure_quick_split - """ - for qsplit in self.parser.list_quick_splits(): - self.parser.configure_quick_split(qsplit) - self.assertTrue( - self.parser.current_option("handedness"), - qsplit - ) - - def test_08(self): - """ - SplitsLeaderboards.export - """ - self.parser.export("test.csv", size="30") - self.assertTrue( - os.path.exists( - os.path.join("out", "test.csv") - ) - ) - - -class TestSeasonStatGrid(unittest.TestCase): - - parser = leaders.SeasonStatGrid() - - @classmethod - def setUpClass(cls): - cls.base_url = cls.parser.page.url - - @classmethod - def tearDownClass(cls): - for file in os.listdir("out"): - os.remove(os.path.join("out", file)) - os.rmdir("out") - cls.parser.quit() - os.system("taskkill /F /IM firefox.exe") - - def test_init(self): - self.assertEqual( - requests.get(self.parser.address).status_code, 200 - ) - self.assertTrue(os.path.exists("out")) - self.assertTrue(self.parser.page) - self.assertTrue(self.parser.soup) - - def test_list_queries(self): - self.assertEqual( - len(self.parser.list_queries()), 13 - ) - - def test_list_options(self): - option_count = { - "stat": 2, "type": 3, "start_season": 71, "end_season": 71, - "popular": 6, "standard": 20, "advanced": 17, "statcast": 8, - "batted_ball": 24, "win_probability": 10, "pitch_type": 25, - "plate_discipline": 25, "value": 11 - } - for query in option_count: - self.assertEqual( - len(self.parser.list_options(query)), option_count[query], - query - ) - - def test_current_option(self): - current_options = { - "stat": "Batting", "type": "Normal", "start_season": "2011", - "end_season": "2020", "popular": "WAR", "standard": "None", - "advanced": "None", "statcast": "None", "batted_ball": "None", - "win_probability": "None", "pitch_type": "None", - "plate_discipline": "None", "value": "WAR" - } - for query in current_options: - self.assertEqual( - self.parser.current_option(query), current_options[query], - query - ) - - def test_configure(self): - self.parser.reset() - queries = self.parser.list_queries() - for query in queries: - option = self.parser.list_options(query)[-1] - self.parser.configure(query, option) - if query not in ["end_season"]: - self.assertEqual( - self.parser.current_option(query), option, - query - ) - self.parser.reset() - - def test_export(self): - self.parser.reset() - self.parser.export("test.csv", size="30") - self.assertTrue( - os.path.exists(os.path.join("out", "test.csv")) - ) - with open(os.path.join("out", "test.csv")) as file: - reader = csv.reader(file) - data = list(reader) - self.assertEqual( - len(data), 31 - ) - self.assertTrue( - all([len(r) == 12 for r in data]) - ) - - def test_reset(self): - self.parser.page.goto("https://google.com") - self.parser.reset() - self.assertEqual( - self.parser.page.url, - self.base_url - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_leaders.py b/tests/test_leaders.py deleted file mode 100644 index ca25948..0000000 --- a/tests/test_leaders.py +++ /dev/null @@ -1,656 +0,0 @@ -#! python3 -# tests/leaders.py - -import unittest - -import bs4 -from playwright.sync_api import sync_playwright -import requests - - -class TestMajorLeagueLeaderboards(unittest.TestCase): - - @classmethod - def setUpClass(cls): - cls.address = "https://fangraphs.com/leaders.aspx" - cls.res = requests.get(cls.address) - cls.soup = bs4.BeautifulSoup(cls.res.text, features="lxml") - - def test_selections_selectors(self): - selectors = { - "group": "#LeaderBoard1_tsGroup", - "stat": "#LeaderBoard1_tsStats", - "position": "#LeaderBoard1_tsPosition", - "type": "#LeaderBoard1_tsType" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_dropdowns_selectors(self): - selectors = { - "league": "#LeaderBoard1_rcbLeague_Input", - "team": "#LeaderBoard1_rcbTeam_Input", - "single_season": "#LeaderBoard1_rcbSeason_Input", - "split": "#LeaderBoard1_rcbMonth_Input", - "min_pa": "#LeaderBoard1_rcbMin_Input", - "season1": "#LeaderBoard1_rcbSeason1_Input", - "season2": "#LeaderBoard1_rcbSeason2_Input", - "age1": "#LeaderBoard1_rcbAge1_Input", - "age2": "#LeaderBoard1_rcbAge2_Input" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_dropdown_options_selectors(self): - selectors = { - "league": "#LeaderBoard1_rcbLeague_DropDown", - "team": "#LeaderBoard1_rcbTeam_DropDown", - "single_season": "#LeaderBoard1_rcbSeason_DropDown", - "split": "#LeaderBoard1_rcbMonth_DropDown", - "min_pa": "#LeaderBoard1_rcbMin_DropDown", - "season1": "#LeaderBoard1_rcbSeason1_DropDown", - "season2": "#LeaderBoard1_rcbSeason2_DropDown", - "age1": "#LeaderBoard1_rcbAge1_DropDown", - "age2": "#LeaderBoard1_rcbAge2_DropDown" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_checkboxes_selectors(self): - selectors = { - "split_teams": "#LeaderBoard1_cbTeams", - "active_roster": "#LeaderBoard1_cbActive", - "hof": "#LeaderBoard1_cbHOF", - "split_seasons": "#LeaderBoard1_cbSeason", - "rookies": "#LeaderBoard1_cbRookie" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_buttons_selectors(self): - selectors = { - "season1": "#LeaderBoard1_btnMSeason", - "season2": "#LeaderBoard1_btnMSeason", - "age1": "#LeaderBoard1_cmdAge", - "age2": "#LeaderBoard1_cmdAge" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_address(self): - self.assertEqual( - requests.get(self.address).status_code, 200 - ) - - def test_list_options_dropdown_selectors(self): - selectors = { - "league": "#LeaderBoard1_rcbLeague_DropDown", - "team": "#LeaderBoard1_rcbTeam_DropDown", - "single_season": "#LeaderBoard1_rcbSeason_DropDown", - "split": "#LeaderBoard1_rcbMonth_DropDown", - "min_pa": "#LeaderBoard1_rcbMin_DropDown", - "season1": "#LeaderBoard1_rcbSeason1_DropDown", - "season2": "#LeaderBoard1_rcbSeason2_DropDown", - "age1": "#LeaderBoard1_rcbAge1_DropDown", - "age2": "#LeaderBoard1_rcbAge2_DropDown" - } - for cat in selectors: - elems = self.soup.select(f"{selectors[cat]} li") - self.assertTrue( - all([isinstance(e.getText(), str) for e in elems]) - ) - - def test_list_options_selections_selectors(self): - selectors = { - "group": "#LeaderBoard1_tsGroup", - "stat": "#LeaderBoard1_tsStats", - "position": "#LeaderBoard1_tsPosition", - "type": "#LeaderBoard1_tsType" - } - for cat in selectors: - elems = self.soup.select(f"{selectors[cat]} li") - self.assertTrue( - all([isinstance(e.getText(), str) for e in elems]) - ) - - def test_current_option_checkbox_selectors(self): - selectors = { - "split_teams": "#LeaderBoard1_cbTeams", - "active_roster": "#LeaderBoard1_cbActive", - "hof": "#LeaderBoard1_cbHOF", - "split_seasons": "#LeaderBoard1_cbSeason", - "rookies": "#LeaderBoard1_cbRookie" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, len(elems) - ) - - def test_current_option_dropdowns_selectors(self): - selectors = { - "league": "#LeaderBoard1_rcbLeague_Input", - "team": "#LeaderBoard1_rcbTeam_Input", - "single_season": "#LeaderBoard1_rcbSeason_Input", - "split": "#LeaderBoard1_rcbMonth_Input", - "min_pa": "#LeaderBoard1_rcbMin_Input", - "season1": "#LeaderBoard1_rcbSeason1_Input", - "season2": "#LeaderBoard1_rcbSeason2_Input", - "age1": "#LeaderBoard1_rcbAge1_Input", - "age2": "#LeaderBoard1_rcbAge2_Input" - } - for cat in selectors: - elem = self.soup.select(selectors[cat])[0] - self.assertIsNotNone( - elem.get("value") - ) - - def test_current_option_selections_selectors(self): - selectors = { - "group": "#LeaderBoard1_tsGroup", - "stat": "#LeaderBoard1_tsStats", - "position": "#LeaderBoard1_tsPosition", - "type": "#LeaderBoard1_tsType" - } - for cat in selectors: - elem = self.soup.select(f"{selectors[cat]} .rtsLink.rtsSelected") - self.assertEqual( - len(elem), 1, cat - ) - self.assertIsInstance(elem[0].getText(), str) - - def test_configure_dropdown_selectors(self): - selectors = { - "league": "#LeaderBoard1_rcbLeague_DropDown", - "team": "#LeaderBoard1_rcbTeam_DropDown", - "single_season": "#LeaderBoard1_rcbSeason_DropDown", - "split": "#LeaderBoard1_rcbMonth_DropDown", - "min_pa": "#LeaderBoard1_rcbMin_DropDown", - "season1": "#LeaderBoard1_rcbSeason1_DropDown", - "season2": "#LeaderBoard1_rcbSeason2_DropDown", - "age1": "#LeaderBoard1_rcbAge1_DropDown", - "age2": "#LeaderBoard1_rcbAge2_DropDown" - } - for cat in selectors: - elems = self.soup.select(f"{selectors[cat]} > div > ul > li") - self.assertTrue(elems) - - def test_configure_selection_selectors(self): - selectors = { - "group": "#LeaderBoard1_tsGroup", - "stat": "#LeaderBoard1_tsStats", - "position": "#LeaderBoard1_tsPosition", - "type": "#LeaderBoard1_tsType" - } - for cat in selectors: - elems = self.soup.select(f"{selectors[cat]} li") - self.assertTrue(elems) - - def test_configure_selection_expand_sublevel(self): - elems = self.soup.select("#LeaderBoard1_tsType a[href='#']") - self.assertEqual(len(elems), 1) - - def test_export_id(self): - elems = self.soup.select("#LeaderBoard1_cmdCSV") - self.assertEqual(len(elems), 1) - - -class TestSplitsLeaderboards(unittest.TestCase): - - @classmethod - def setUpClass(cls): - cls.page = browser.new_page() - cls.address = "https://www.fangraphs.com/leaders/splits-leaderboards" - cls.page.goto(cls.address, timeout=0) - cls.soup = bs4.BeautifulSoup( - cls.page.content(), features="lxml" - ) - - def test_selections_selectors(self): - selectors = { - "group": [ - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(1)", - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(2)", - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(3)", - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(4)" - ], - "stat": [ - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(6)", - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(7)" - ], - "type": [ - "#root-buttons-stats > div:nth-child(1)", - "#root-buttons-stats > div:nth-child(2)", - "#root-buttons-stats > div:nth-child(3)" - ] - } - for cat in selectors: - for sel in selectors[cat]: - elems = self.soup.select(sel) - self.assertEqual( - len(elems), 1, (cat, sel) - ) - - def test_dropdowns_selectors(self): - selectors = { - "time_filter": "#root-menu-time-filter > .fg-dropdown.splits.multi-choice", - "preset_range": "#root-menu-time-filter > .fg-dropdown.splits.single-choice", - "groupby": ".fg-dropdown.group-by" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_splits_selectors(self): - selectors = { - "handedness": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(1)", - "home_away": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(2)", - "batted_ball": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(3)", - "situation": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(4)", - "count": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(5)", - "batting_order": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(1)", - "position": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(2)", - "inning": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(3)", - "leverage": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(4)", - "shifts": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(5)", - "team": ".fgBin:nth-child(3) > .fg-dropdown.splits.multi-choice:nth-child(1)", - "opponent": ".fgBin:nth-child(3) > .fg-dropdown.splits.multi-choice:nth-child(2)", - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_quick_splits_selectors(self): - selectors = { - "batting_home": ".quick-splits > div:nth-child(1) > div:nth-child(2) > .fgButton:nth-child(1)", - "batting_away": ".quick-splits > div:nth-child(1) > div:nth-child(2) > .fgButton:nth-child(2)", - "vs_lhp": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(1)", - "vs_lhp_home": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(2)", - "vs_lhp_away": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(3)", - "vs_lhp_as_lhh": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(4)", - "vs_lhp_as_rhh": ".quick-splits > div:nth-child(1) > div:nth-child(3) > .fgButton:nth-child(5)", - "vs_rhp": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(1)", - "vs_rhp_home": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(2)", - "vs_rhp_away": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(3)", - "vs_rhp_as_lhh": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(4)", - "vs_rhp_as_rhh": ".quick-splits > div:nth-child(1) > div:nth-child(4) > .fgButton:nth-child(5)", - "pitching_as_sp": ".quick-splits > div:nth-child(2) > div:nth-child(1) .fgButton:nth-child(1)", - "pitching_as_rp": ".quick-splits > div:nth-child(2) > div:nth-child(1) .fgButton:nth-child(2)", - "pitching_home": ".quick-splits > div:nth-child(2) > div:nth-child(2) > .fgButton:nth-child(1)", - "pitching_away": ".quick-splits > div:nth-child(2) > div:nth-child(2) > .fgButton:nth-child(2)", - "vs_lhh": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(1)", - "vs_lhh_home": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(2)", - "vs_lhh_away": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(3)", - "vs_lhh_as_rhp": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(4)", - "vs_lhh_as_lhp": ".quick-splits > div:nth-child(2) > div:nth-child(3) > .fgButton:nth-child(5)", - "vs_rhh": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)", - "vs_rhh_home": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)", - "vs_rhh_away": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)", - "vs_rhh_as_rhp": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)", - "vs_rhh_as_lhp": ".quick-splits > div:nth-child(2) > div:nth-child(4) > .fgButton:nth-child(1)" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_switches_selectors(self): - selectors = { - "split_teams": "#stack-buttons > div:nth-child(2)", - "auto_pt": "#stack-buttons > div:nth-child(3)" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_reset_filters_selector(self): - selector = "#stack-buttons div[class='fgButton small']:nth-last-child(1)" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 1 - ) - - def test_configure_filter_group_selector(self): - groups = ["Quick Splits", "Splits", "Filters", "Show All"] - selector = ".fgBin.splits-bin-controller div" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 4 - ) - self.assertEqual( - [e.getText() for e in elems], groups - ) - - def test_update_button_selector(self): - selector = "#button-update" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 0 - ) - - def test_current_option_selections(self): - selectors = { - "group": [ - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(1)", - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(2)", - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(3)", - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(4)" - ], - "stat": [ - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(6)", - ".fgBin.row-button > div[class*='button-green fgButton']:nth-child(7)" - ], - "type": [ - "#root-buttons-stats > div:nth-child(1)", - "#root-buttons-stats > div:nth-child(2)", - "#root-buttons-stats > div:nth-child(3)" - ] - } - for query in selectors: - class_attributes = [] - for sel in selectors[query]: - elem = self.soup.select(sel)[0] - self.assertTrue(elem.get("class")) - class_attributes.append(elem.get("class")) - self.assertEqual( - ["isActive" in attr for attr in class_attributes].count(True), - 1 - ) - - def test_current_option_dropdowns(self): - selectors = { - "time_filter": "#root-menu-time-filter > .fg-dropdown.splits.multi-choice", - "preset_range": "#root-menu-time-filter > .fg-dropdown.splits.single-choice", - "groupby": ".fg-dropdown.group-by" - } - for query in selectors: - elems = self.soup.select(f"{selectors[query]} ul li") - for elem in elems: - self.assertTrue(elem.get("class")) - - def test_current_option_splits(self): - selectors = { - "handedness": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(1)", - "home_away": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(2)", - "batted_ball": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(3)", - "situation": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(4)", - "count": ".fgBin:nth-child(1) > .fg-dropdown.splits.multi-choice:nth-child(5)", - "batting_order": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(1)", - "position": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(2)", - "inning": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(3)", - "leverage": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(4)", - "shifts": ".fgBin:nth-child(2) > .fg-dropdown.splits.multi-choice:nth-child(5)", - "team": ".fgBin:nth-child(3) > .fg-dropdown.splits.multi-choice:nth-child(1)", - "opponent": ".fgBin:nth-child(3) > .fg-dropdown.splits.multi-choice:nth-child(2)", - } - for query in selectors: - elems = self.soup.select(f"{selectors[query]} ul li") - for elem in elems: - self.assertTrue(elem.get("class")) - - def test_current_option_switches(self): - selectors = { - "split_teams": "#stack-buttons > div:nth-child(2)", - "auto_pt": "#stack-buttons > div:nth-child(3)" - } - for query in selectors: - elem = self.soup.select(selectors[query])[0] - self.assertTrue(elem.get("class")) - - def test_expand_table_dropdown_selector(self): - selector = ".table-page-control:nth-child(3) select" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 1 - ) - - def test_expand_table_dropdown_options_selectors(self): - options = ["30", "50", "100", "200", "Infinity"] - selector = ".table-page-control:nth-child(3) select option" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 5 - ) - self.assertEqual( - [e.getText() for e in elems], options - ) - - def test_sortby_option_selectors(self): - selector = ".table-scroll thead tr th" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 24 - ) - - def test_write_table_headers_selector(self): - selector = ".table-scroll thead tr th" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 24 - ) - - def test_write_table_rows_selector(self): - selector = ".table-scroll tbody tr" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 30 - ) - for elem in elems: - item_elems = elem.select("td") - self.assertEqual(len(item_elems), 24) - - -class TestSeasonStatGrid(unittest.TestCase): - - @classmethod - def setUpClass(cls): - cls.page = browser.new_page() - cls.address = "https://www.fangraphs.com/leaders/season-stat-grid" - cls.page.goto(cls.address, timeout=0) - cls.soup = bs4.BeautifulSoup( - cls.page.content(), features="lxml" - ) - - def test_base_address(self): - self.assertEqual( - requests.get(self.address).status_code, 200 - ) - - def test_selections_selectors(self): - selectors = { - "stat": [ - "div[class*='fgButton button-green']:nth-child(1)", - "div[class*='fgButton button-green']:nth-child(2)" - ], - "type": [ - "div[class*='fgButton button-green']:nth-child(4)", - "div[class*='fgButton button-green']:nth-child(5)", - "div[class*='fgButton button-green']:nth-child(6)" - ] - } - for cat in selectors: - for sel in selectors[cat]: - elems = self.soup.select(sel) - self.assertEqual( - len(elems), 1, (cat, sel) - ) - - def test_dropdown_selectors(self): - selectors = { - "start_season": ".row-season > div:nth-child(2)", - "end_season": ".row-season > div:nth-child(4)", - "popular": ".season-grid-controls-dropdown-row-stats > div:nth-child(1)", - "standard": ".season-grid-controls-dropdown-row-stats > div:nth-child(2)", - "advanced": ".season-grid-controls-dropdown-row-stats > div:nth-child(3)", - "statcast": ".season-grid-controls-dropdown-row-stats > div:nth-child(4)", - "batted_ball": ".season-grid-controls-dropdown-row-stats > div:nth-child(5)", - "win_probability": ".season-grid-controls-dropdown-row-stats > div:nth-child(6)", - "pitch_type": ".season-grid-controls-dropdown-row-stats > div:nth-child(7)", - "plate_discipline": ".season-grid-controls-dropdown-row-stats > div:nth-child(8)", - "value": ".season-grid-controls-dropdown-row-stats > div:nth-child(9)" - } - for cat in selectors: - elems = self.soup.select(selectors[cat]) - self.assertEqual( - len(elems), 1, cat - ) - - def test_list_options_selections(self): - selectors = { - "stat": [ - "div[class*='fgButton button-green']:nth-child(1)", - "div[class*='fgButton button-green']:nth-child(2)" - ], - "type": [ - "div[class*='fgButton button-green']:nth-child(4)", - "div[class*='fgButton button-green']:nth-child(5)", - "div[class*='fgButton button-green']:nth-child(6)" - ] - } - for cat in selectors: - elems = [ - self.soup.select(sel)[0] - for sel in selectors[cat] - ] - options = [e.getText() for e in elems] - self.assertEqual( - len(options), len(selectors[cat]) - ) - - def test_list_options_dropdowns(self): - selectors = { - "start_season": ".row-season > div:nth-child(2)", - "end_season": ".row-season > div:nth-child(4)", - "popular": ".season-grid-controls-dropdown-row-stats > div:nth-child(1)", - "standard": ".season-grid-controls-dropdown-row-stats > div:nth-child(2)", - "advanced": ".season-grid-controls-dropdown-row-stats > div:nth-child(3)", - "statcast": ".season-grid-controls-dropdown-row-stats > div:nth-child(4)", - "batted_ball": ".season-grid-controls-dropdown-row-stats > div:nth-child(5)", - "win_probability": ".season-grid-controls-dropdown-row-stats > div:nth-child(6)", - "pitch_type": ".season-grid-controls-dropdown-row-stats > div:nth-child(7)", - "plate_discipline": ".season-grid-controls-dropdown-row-stats > div:nth-child(8)", - "value": ".season-grid-controls-dropdown-row-stats > div:nth-child(9)" - } - elem_count = { - "start_season": 71, "end_season": 71, "popular": 6, - "standard": 20, "advanced": 17, "statcast": 8, "batted_ball": 24, - "win_probability": 10, "pitch_type": 25, "plate_discipline": 25, - "value": 11 - } - for cat in selectors: - elems = self.soup.select( - f"{selectors[cat]} li" - ) - self.assertEqual( - len(elems), elem_count[cat] - ) - self.assertTrue( - all([e.getText() for e in elems]) - ) - - def test_current_option_selections(self): - selector = "div[class='fgButton button-green active isActive']" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 2 - ) - - def test_current_options_dropdowns(self): - selectors = { - "start_season": ".row-season > div:nth-child(2)", - "end_season": ".row-season > div:nth-child(4)", - "popular": ".season-grid-controls-dropdown-row-stats > div:nth-child(1)", - "standard": ".season-grid-controls-dropdown-row-stats > div:nth-child(2)", - "advanced": ".season-grid-controls-dropdown-row-stats > div:nth-child(3)", - "statcast": ".season-grid-controls-dropdown-row-stats > div:nth-child(4)", - "batted_ball": ".season-grid-controls-dropdown-row-stats > div:nth-child(5)", - "win_probability": ".season-grid-controls-dropdown-row-stats > div:nth-child(6)", - "pitch_type": ".season-grid-controls-dropdown-row-stats > div:nth-child(7)", - "plate_discipline": ".season-grid-controls-dropdown-row-stats > div:nth-child(8)", - "value": ".season-grid-controls-dropdown-row-stats > div:nth-child(9)" - } - for cat in selectors: - elems = self.soup.select( - f"{selectors[cat]} li[class$='highlight-selection']" - ) - if cat in ["start_season", "end_season", "popular", "value"]: - self.assertEqual( - len(elems), 1, cat - ) - self.assertIsNotNone( - elems[0].getText() - ) - else: - self.assertEqual( - len(elems), 0, cat - ) - - def test_expand_table_dropdown_selector(self): - selector = ".table-page-control:nth-child(3) select" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 1 - ) - - def test_expand_table_dropdown_options_selectors(self): - options = ["30", "50", "100", "200", "Infinity"] - selector = ".table-page-control:nth-child(3) select option" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 5 - ) - self.assertEqual( - [e.getText() for e in elems], options - ) - - def test_sortby_option_selectors(self): - selector = ".table-scroll thead tr th" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 12 - ) - - def test_write_table_headers_selector(self): - selector = ".table-scroll thead tr th" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 12 - ) - - def test_write_table_rows_selector(self): - selector = ".table-scroll tbody tr" - elems = self.soup.select(selector) - self.assertEqual( - len(elems), 30 - ) - for elem in elems: - item_elems = elem.select("td") - self.assertEqual(len(item_elems), 12) - - -if __name__ == "__main__": - with sync_playwright() as play: - browser = play.chromium.launch() - unittest.main()