import time from appium.webdriver.common.appiumby import AppiumBy as By from selenium.common.exceptions import ( StaleElementReferenceException, TimeoutException) from helpers.platform.android.actions import slow_typing_with_actions from helpers.platform.android.locators import ( resource_id_xpath, content_desc_xpath, text_view_xpath) from helpers.appium.keyboard import hide_keyboard from ..base_screen import BaseScreen from ..mixins.utils import retry_on_assertion class ChartsDigestScreen(BaseScreen): charts_list_header = resource_id_xpath('charts-digest-screen-header') spotify_label = text_view_xpath('Spotify') apple_music_label = text_view_xpath('Apple Music') daily_top_200 = text_view_xpath('Daily Top 200') weekly_top_200 = text_view_xpath('Weekly Top 200') daily_viral_100 = text_view_xpath('Daily Viral 100') daily_top_100 = text_view_xpath('Daily Top 100') charts_header = resource_id_xpath('chartsDigestDetailsScreenHeader') information_icon = resource_id_xpath('informationIcon') i_popup_close_btn = resource_id_xpath('closeButton') daily_top_200_i_popup_header = f'({text_view_xpath("Daily Top 200")})[1]' daily_top_200_i_popup_content = text_view_xpath( 'Spotify Daily Top 200 streams show the chart qualifying streams for ' 'each market. This data comes directly from Spotify Charts, so the ' 'value may differ slightly from our typical streaming numbers.') weekly_top_200_i_popup_header = ( f'({text_view_xpath("Weekly Top 200")})[1]') weekly_top_200_i_popup_content = ( '(' + text_view_xpath( 'Spotify Weekly Top 200 streams show the chart qualifying streams ' 'for each market. This data comes directly from Spotify Charts, ' 'so the value may differ slightly from our typical streaming ' 'numbers.') + ')[1]') daily_top_100_i_popup_header = f'({text_view_xpath("Daily Top 100")})[1]' select_charts_type_button = resource_id_xpath('dailyTopButton') country_filter_btn = resource_id_xpath('countryFilterButton') search_bar = resource_id_xpath('searchBar') search_country_bar = resource_id_xpath('searchBar') no_songs_message = resource_id_xpath('noData') cover_art = resource_id_xpath('coverArt') song_name = ( cover_art + '/following-sibling::android.widget.TextView[1]') song_artist = ( cover_art + '/following-sibling::android.widget.TextView[2]') song_position_num = ( cover_art + '/following-sibling::android.widget.TextView[3]') song_streams = ( cover_art + '/following-sibling::android.widget.TextView[4]') blue_dot = '//android.view.ViewGroup[@resource-id="bullet"]' apply_btn = resource_id_xpath('applyButton') def assert_charts_digest_is_displayed(self): self.waiter(20, By.XPATH, self.charts_list_header) def assert_charts_list_correct(self): self.waiter(3, By.XPATH, self.spotify_label) self.waiter(3, By.XPATH, self.apple_music_label) self.waiter(3, By.XPATH, self.daily_top_200) self.waiter(3, By.XPATH, self.weekly_top_200) self.waiter(3, By.XPATH, self.daily_viral_100) self.waiter(3, By.XPATH, self.daily_top_100) def tap_chart_card(self, chart_name): chart_name_xpath = text_view_xpath(chart_name) self.click_with_retry(10, By.XPATH, chart_name_xpath) def verify_charts_screen_displayed(self): self.waiter(10, By.XPATH, self.charts_header) def assert_country_filter_selected(self, country_option): self.waiter(10, By.ACCESSIBILITY_ID, country_option) @retry_on_assertion def assert_songs_filtered_by_brand(self): # Check how many fully visible song cards are displayed on the screen all_visible_songs = ( self.waiter(30, By.XPATH, resource_id_xpath('growthPercentage'), multiple=True)) assert len(all_visible_songs) >= 4, ( f"Expected at least 4 visible songs, " f"but found {len(all_visible_songs)}" ) icon_loc = ("//*[@resource-id='coverArt']/parent::" "android.view.ViewGroup/following-sibling::" "android.view.ViewGroup//com.horcrux.svg.SvgView") all_brand_icons = self.waiter(5, By.XPATH, icon_loc, multiple=True) assert len(all_visible_songs) == len(all_brand_icons), ( f"Expected each visible song to have a brand icon. " f"Songs found: {len(all_visible_songs)}, " f"brand icons found: {len(all_brand_icons)}" ) def select_chart_type(self, chart_type_name): charts = \ {'Daily Top 200': 'spotify_regional_daily', 'Weekly Top 200': 'spotify_regional_weekly', 'Daily Viral 100': 'spotify_viral_daily'} self.waiter(10, By.XPATH, self.select_charts_type_button) time.sleep(3) self.click_with_retry(3, By.XPATH, self.select_charts_type_button) self.click_with_retry(10, By.XPATH, resource_id_xpath(charts[chart_type_name])) def tap_information(self): time.sleep(3) self.click_with_retry(10, By.XPATH, self.information_icon) def tap_market_selector(self): time.sleep(3) self.click_with_retry(10, By.XPATH, self.country_filter_btn) def select_country(self, country): country_xpath = content_desc_xpath(country) try: self.click_with_retry(10, By.XPATH, country_xpath) except TimeoutException: self.click_with_retry(10, By.XPATH, self.search_country_bar) for _ in range(2): try: search_input = self.waiter(10, By.XPATH, self.search_country_bar) slow_typing_with_actions(self.driver, search_input, country, delay=0.25) break except StaleElementReferenceException: time.sleep(1) hide_keyboard(self.driver) time.sleep(1) self.click_with_retry(10, By.XPATH, country_xpath) def assert_empty_state(self): self.waiter(20, By.XPATH, self.no_songs_message) def verify_daily_top_200_popup_content(self): self.waiter(10, By.XPATH, self.daily_top_200_i_popup_header) self.waiter(10, By.XPATH, self.daily_top_200_i_popup_content) self.click_with_retry(3, By.XPATH, self.i_popup_close_btn) def verify_weekly_top_200_popup_content(self): self.waiter(10, By.XPATH, self.weekly_top_200_i_popup_header) self.waiter(10, By.XPATH, self.weekly_top_200_i_popup_content) self.click_with_retry(3, By.XPATH, self.i_popup_close_btn) def verify_daily_top_100_popup_content(self): self.waiter(10, By.XPATH, self.daily_top_100_i_popup_header) self.click_with_retry(3, By.XPATH, self.i_popup_close_btn) def assert_songs_sorted_by_position(self): positions = self.waiter( 10, By.XPATH, self.song_position_num, multiple=True) formatted_positions = [int(i.text.replace('#', '')) for i in positions] assert formatted_positions == sorted(formatted_positions) def assert_songs_sorted_by_blue_dot(self): new_entry_songs = self.waiter( 20, By.XPATH, self.blue_dot, multiple=True) assert new_entry_songs # Scroll down until bullets disappear, then assert position-based order scroll_times = 0 for _ in range(10): try: self.waiter(20, By.XPATH, self.blue_dot, multiple=True) self.scroll_page() scroll_times += 1 except TimeoutException: # No bullets found after scroll => # we've reached the point where "new entries" end self.assert_songs_sorted_by_position() break # Scroll back up approximately to where we started # Appium can report header elements as visible when they # are not actually user-visible, so we avoid scroll_and_assert() # and return by the number of performed scrolls. self.scroll_page('up', False, scroll_times) def assert_songs_sorted_by_positive_trend_desc(self): trend_arrow = '//android.view.ViewGroup[@resource-id="trendArrow"]' caret = f'{trend_arrow}/com.horcrux.svg.SvgView' amount = f'{trend_arrow}/following-sibling::android.widget.TextView' carets = self.waiter(20, By.XPATH, caret, multiple=True) amounts = self.waiter(10, By.XPATH, amount, multiple=True) carets = [i.get_attribute('resourceId') for i in carets] amounts = [ int(i.text.replace('+', '').replace('-', '')) if i.text else 0 for i in amounts] # Pair them together trends = list(zip(carets, amounts)) # Split into groups ups = [amt for caret, amt in trends if "caretUp" in caret] zeros = [amt for caret, amt in trends if caret is None or caret == ""] downs = [amt for caret, amt in trends if "caretDown" in caret] # 1. Positive trends must be sorted descending assert ups == sorted(ups, reverse=True), \ f"Positive trends not sorted desc: {ups}" # 2. Zero trends must all be 0 assert all(v == 0 for v in zeros), \ f"Non-zero values found in zero trends: {zeros}" # 3. Negative trends must be sorted ascending assert downs == sorted(downs), \ f"Negative trends not sorted asc: {downs}" # 4. Ensure order in list: ups → zeros → downs caret_sequence = \ [("up" if "caretUp" in c else "down" if "caretDown" in c else "zero") for c in carets] expected_order = ( sorted(caret_sequence, key=lambda x: {"up": 0, "zero": 1, "down": 2}[x])) assert caret_sequence == expected_order, \ f"Unexpected caret order: {caret_sequence}" def assert_songs_sorted_by_major_moves(self): for _ in range(5): try: self.waiter(5, By.XPATH, self.blue_dot, multiple=True) self.assert_songs_sorted_by_blue_dot() except TimeoutException: self.assert_songs_sorted_by_positive_trend_desc() for _ in range(5): self.scroll_page() def tap_song_number(self, context, song_number): song_names = self.waiter( 10, By.XPATH, self.song_name, multiple=True) song_artists = self.waiter( 3, By.XPATH, self.song_artist, multiple=True) context.track_name_of_song = song_names[song_number].text context.track_artist_of_song = song_artists[song_number].text song_names[song_number].click() def _assert_song_card_attributes(self, include_streams=True): songs = self.waiter(20, By.XPATH, self.cover_art, multiple=True) assert len(songs) >= 3 visible_songs = songs[:-1] # avoid partially visible bottom card for index, _ in enumerate(visible_songs): try: base = f"({self.cover_art})[{index + 1}]" attributes = { "Song Title": "/following-sibling::android.widget.TextView[1]", "Artist Name": "/following-sibling::android.widget.TextView[2]", "Position": "/following-sibling::android.widget.TextView[3]", } for label, path in attributes.items(): try: self.assert_element(By.XPATH, base + path) except Exception as e: raise AssertionError( f'Song card #{index + 1} missing "{label}". ' f'Locator: {base + path}. Error: {e}' ) streams = ( base + "/following-sibling::android.widget.TextView[4]") if include_streams: self.assert_element(By.XPATH, streams) else: assert not self.driver.find_elements(By.XPATH, streams), \ f"'Streams' should NOT be present in card #{index + 1}" except Exception as e: raise AssertionError( f"Song card #{index + 1} failed validation: {e}" ) def assert_expected_song_attributes_for_non_top_100(self): self._assert_song_card_attributes(include_streams=True) def assert_expected_song_attributes_for_top_100(self): self._assert_song_card_attributes(include_streams=False) def scroll_through_the_song_list(self, times): self.verify_charts_screen_displayed() for i in range(times): self.scroll_page() time.sleep(3) # allow scroll to finish def scroll_to_the_top(self): self.scroll_and_assert(By.XPATH, self.information_icon, max_scrolls=10, direction='up') def _get_song_titles_and_artists(self): for _ in range(2): try: song_names = self.waiter(10, By.XPATH, self.song_name, multiple=True) song_artists = self.waiter(3, By.XPATH, self.song_artist, multiple=True) song_names_text = [el.text for el in song_names] song_artists_text = [el.text for el in song_artists] return song_names_text, song_artists_text except StaleElementReferenceException: time.sleep(3) raise StaleElementReferenceException( "Failed to retrieve song titles and artists due to DOM updates.") def verify_song_list_page_position(self, context): song_names, song_artists = self._get_song_titles_and_artists() assert context.track_name_of_song in song_names assert context.track_artist_of_song in song_artists, song_artists def search_via_chart_screen_header(self, search_term): self.click_with_retry(10, By.XPATH, self.search_bar) self.waiter(3, By.XPATH, self.search_bar).send_keys(search_term) def verify_search_results_contains_search_term(self, search_term): song_names, song_artists = self._get_song_titles_and_artists() results = list(zip(song_names, song_artists)) for song, artist in results: combined = f"{song} {artist}".lower() assert search_term in combined, \ f"'{search_term}' not found in: {song} / {artist}" def filter_songs_by_my_catalog(self): self.click_with_retry(10, By.XPATH, resource_id_xpath('filterButton')) self.click_with_retry(10, By.XPATH, resource_id_xpath('myCatalogSwitch')) self.click_with_retry(10, By.XPATH, self.apply_btn) def filter_songs_by_brand(self, brand): self.click_with_retry(10, By.XPATH, resource_id_xpath('filterButton')) loc = ('(//android.widget.SeekBar[@content-desc="Bottom Sheet"])[2]//' f'android.view.ViewGroup[@content-desc="{brand}"]') self.click_with_retry(10, By.XPATH, loc) self.click_with_retry(5, By.XPATH, self.apply_btn) def verify_no_results_message(self): no_results_title = text_view_xpath('No Results') self.waiter(10, By.XPATH, no_results_title)