from time import sleep from datetime import datetime from appium.webdriver.common.appiumby import AppiumBy as By from selenium.common.exceptions import TimeoutException, \ StaleElementReferenceException, NoSuchElementException from page_objects.base_screen import BaseScreen from page_objects.android_pom.sound_recording_screen import ( SoundRecordingScreen) from page_objects.android_pom.product_screen import ProductScreen from page_objects.mixins.playlists_mixin import PlaylistsMixin from page_objects.mixins.utils import (parse_custom_format, retry_on_assertion, retry_on_stale_element) from helpers.test_data import localization from helpers.platform.android.locators import ( resource_id_xpath, content_desc_xpath, text_view_xpath, xpath_literal) class HomeScreen(BaseScreen, PlaylistsMixin): songs_tab = f'({content_desc_xpath("Songs")})[2]' products_tab = content_desc_xpath('Products') + '//*[@text="Products"]' artists_tab = content_desc_xpath('Artists') playlists_tab = content_desc_xpath('Playlists') labels_tab = content_desc_xpath('Labels') catalog_tab = resource_id_xpath('myCatalogTab') my_favorites_tab = resource_id_xpath('starredTab') chart_digest_tab = "Charts" search_tab = resource_id_xpath('searchTab') account_tab = resource_id_xpath('accountTab') sound_recording_in_playlist_item = resource_id_xpath('songTile') product_item = recent_product = '//*[contains(@resource-id, "product")]' top_track_item = '//*[contains(@resource-id, "topTracksItem")]' streams_sorting_btn = '//*[contains(@content-desc, "STREAMS • ")]' release_date_sorting_btn = 'RELEASE DATE' stream_count = '//android.widget.TextView[@resource-id="streamCount"]' filter_icon = '//*[contains(@text, "FILTERS")]' search_bar = resource_id_xpath('searchBar') apply_btn = '//*[contains(@text, "APPLY")]' times_scrolled = 0 no_songs_msg_title = text_view_xpath('No songs') no_products_msg_title = text_view_xpath('No products') no_artists_msg_title = text_view_xpath('No Artists') no_items_msg_body = text_view_xpath( 'You can adjust the filters to broaden your search') no_items_reset_btn = 'RESET' # Artists tab artist_order = resource_id_xpath('orderLabel') artist_cover = ('//android.view.ViewGroup[contains(' '@content-desc, "participantComponent")]//' 'android.widget.ImageView') artist_name = resource_id_xpath('participantName') artist_stream_count = resource_id_xpath('streamCount') no_artists_title = text_view_xpath('No Artists') no_artists_content = \ text_view_xpath('You can adjust the filters to broaden your search') no_artists_reset_btn = resource_id_xpath('reset') # Products tab product_order = resource_id_xpath('orderLabel') product_icon = resource_id_xpath('productIcon') product_name = resource_id_xpath('productName') product_artist_name = resource_id_xpath('artistName') product_stream_count = resource_id_xpath('streamCount') # Songs tab song_order = resource_id_xpath('orderLabel') song_cover = resource_id_xpath('coverArt') song_name = \ ('//android.view.ViewGroup[' 'starts-with(@resource-id, "topTracksItem")]/' 'android.view.ViewGroup/android.widget.TextView[1]') song_artist_name = resource_id_xpath('songArtistLabel') song_stream_count = resource_id_xpath('streamCount') def go_home(self): retries = 0 wait_timer = 20 while True: if retries >= 3: break self.waiter(wait_timer, By.XPATH, self.catalog_tab).click() try: self.assert_top_tracks() break except TimeoutException: retries += 1 wait_timer += 10 def verify_my_catalog_screen_is_localized(self, context): localization_data = ( localization.MY_CATALOG_LOCALIZATION)[context.current_language] for el in localization_data['tabs'].values(): self.waiter(10, By.XPATH, content_desc_xpath(el)) self.waiter(10, By.XPATH, content_desc_xpath(localization_data['filter'])) def open_songs_tab(self): fallback_locator = content_desc_xpath('Songs') try: self.waiter(5, By.XPATH, self.songs_tab).click() except TimeoutException: self.waiter(3, By.XPATH, fallback_locator).click() # Temporary workaround: # On BrowserStack, some devices unexpectedly auto-scroll down # after navigating to this tab, so we restore the expected position sleep(3) self.scroll_page(direction='up', times=1) def open_products_tab(self): self.waiter(5, By.XPATH, self.products_tab).click() def open_artists_tab(self): self.waiter(5, By.XPATH, self.artists_tab).click() def open_labels_tab(self): self.waiter(5, By.XPATH, self.labels_tab).click() def open_playlists_tab(self): for i in range(0, 1): try: self.click_with_retry(10, By.XPATH, self.playlists_tab) self.waiter(40, By.XPATH, self.playlist_element) except TimeoutException: continue break def open_home_tab(self): self.waiter(10, By.XPATH, self.catalog_tab).click() def open_search_tab(self, context=None): self.waiter(10, By.XPATH, self.search_tab).click() def open_my_favorites_tab(self): self.waiter(10, By.XPATH, self.my_favorites_tab).click() def open_chart_digest_screen(self): self.waiter(10, By.ACCESSIBILITY_ID, self.chart_digest_tab).click() def select_item(self, item_type, number, index_offset=0): items_map = { 'track': self.top_track_item, 'product': self.product_item, 'recording': self.sound_recording_in_playlist_item } item_locator = items_map[item_type] number = int(number) + index_offset if number >= 7: self.scroll_page() self.times_scrolled += 1 number = number - self.times_scrolled item_xpath = f'({item_locator})[{number}]' self.scroll_and_assert(By.XPATH, item_xpath, small_scroll=True, wd_timeout=5) self.click_with_retry(10, By.XPATH, item_xpath) def assert_top_tracks(self): sleep(5) # Wait for all tracks to appear dynamically in the list assert self.waiter(20, By.XPATH, self.top_track_item) def assert_header(self): sound_recording_screen = SoundRecordingScreen(self.driver) for num in range(2): # Without a sleep command, Appium clicks don't affect the UI sleep(1) self.select_item('track', num + 1) sound_recording_screen.scroll_header("left") header_info = sound_recording_screen.header_info() # TODO: Investigate bug where the 'Label' field can be empty. # Recheck with developers. print(header_info) # assert header_info["Label"] == label self.press_back_button() def assert_multiple_track_labels_correctness(self, label): sound_recording_screen = SoundRecordingScreen(self.driver) for num in range(2): # Without a sleep command, Appium clicks don't affect the UI sleep(1) self.select_item('track', num + 1) sound_recording_screen.scroll_header("left") header_info = sound_recording_screen.header_info() if header_info["Label"] != "-": assert header_info["Label"] == label self.press_back_button() def assert_multiple_track_brands_correctness(self, brand): sound_recording_screen = SoundRecordingScreen(self.driver) for num in range(2): # Without a sleep command, Appium clicks don't affect the UI sleep(1) self.select_item('track', num + 1) sound_recording_screen.scroll_header("left") sound_recording_screen.scroll_header("left") header_info = sound_recording_screen.header_info() if header_info["Brand"] != "-": assert header_info["Brand"] == brand self.press_back_button() def assert_track_release_date_sorting(self): sound_recording_screen = SoundRecordingScreen(self.driver) dates = [] for num in range(1, 6, 2): # Without a sleep command, Appium clicks don't affect the UI sleep(1) self.select_item('track', num) sound_recording_screen.scroll_header("left") header_info = sound_recording_screen.header_info() dates.append(header_info['Release Date']) self.press_back_button() dates = [datetime.strptime(date, '%d %b %Y').date() for date in dates] dates_sorted = sorted(dates, reverse=True) assert dates == dates_sorted def assert_multiple_product_labels_correctness(self, label): self.open_products_tab() product_screen = ProductScreen(self.driver) for num in range(3): # Without a sleep command, Appium clicks don't affect the UI sleep(1) self.select_item('product', num + 1, index_offset=1) product_screen.scroll_header("left") header_info = product_screen.header_info() assert header_info["Label"] == label self.press_back_button() def assert_multiple_product_brands_correctness(self, brand): self.open_products_tab() product_screen = ProductScreen(self.driver) for num in range(3): # Without a sleep command, Appium clicks don't affect the UI sleep(1) self.select_item('product', num + 1, index_offset=1) product_screen.scroll_header("left") product_screen.scroll_header("left") header_info = product_screen.header_info() assert header_info["Brand"] == brand self.press_back_button() def assert_multiple_playlist_labels_correctness(self, label): self.open_playlists_tab() sound_recording_screen = SoundRecordingScreen(self.driver) for num in range(3): # Without a sleep command, Appium clicks don't affect the UI sleep(1) self.select_item('recording', num + 1) sound_recording_screen.scroll_header("left") header_info = sound_recording_screen.header_info() assert header_info["Label"] == label self.press_back_button() def assert_tracks_belong_to_country(self, country): srs = SoundRecordingScreen(self.driver) for num in range(3): # Without a sleep command, Appium clicks don't affect the UI sleep(1) self.select_item('track', num + 1) srs.click_streaming_trend_tab("Country") countries = ( srs.get_all_values_from_total_column_in_streaming_trend()) assert country in countries self.press_back_button() @retry_on_stale_element() def assert_songs_with_only_artist(self, artist): artists = self.waiter( 10, By.XPATH, self.song_artist_name, multiple=True) artist_names = [artist.text for artist in artists] assert len(set(artist_names)) == 1 assert artist_names[0] == artist def assert_playlists(self): assert self.waiter(40, By.XPATH, self.playlist_element) def assert_products(self): self.waiter(20, By.XPATH, self.recent_product) def filter(self, category, search_term): self.waiter(10, By.XPATH, self.filter_icon).click() self.waiter(10, By.XPATH, f'//*[contains(@text, "{category.upper()}")]').click() if category.upper() != 'BRAND': self.waiter(10, By.XPATH, self.search_bar).send_keys(search_term) sleep(3) self.click_with_retry(10, By.XPATH, f'//android.widget.TextView' f'[contains(@text, "{search_term}")]') else: self.waiter(10, By.ACCESSIBILITY_ID, search_term).click() self.waiter(10, By.XPATH, self.apply_btn).click() @retry_on_assertion(max_retries=2, delay=5) def assert_values_sorted_by_custom_format(self): """ Asserts that elements are sorted by formatted numerical values in descending order. Handles formats like '1.2M' (millions) and '90K' (thousands), converting them to numerical values and checks if they are sorted from the largest to the smallest. """ def is_sorted(values): return all( values[i] >= values[i + 1] for i in range(len(values) - 1)) for _ in range(2): try: formatted_values = [elem.text for elem in self.waiter(30, By.XPATH, self.stream_count, multiple=True)] break except StaleElementReferenceException: sleep(1) else: raise Exception( "Failed to retrieve stream count elements after retries.") numerical_values = [parse_custom_format(value) for value in formatted_values] assert is_sorted(numerical_values), numerical_values @retry_on_stale_element() def assert_products_sorted_by_release_date(self): """ Asserts that the first product in the list has a newer release date than the last product (i.e., sorted descending by release date). """ # Open first product and get release date products = self.waiter(10, By.XPATH, self.product_name, multiple=True) self.click_with_retry(element=products[0]) product_screen_1 = ProductScreen(self.driver) product_screen_1.scroll_header("left") date_str_1 = product_screen_1.get_all_header_fields()["Release Date"] self.press_back_button() # Open last product and get release date products = self.waiter(10, By.XPATH, self.product_name, multiple=True) self.tap_after_overlap_check(products[-1]) product_screen_2 = ProductScreen(self.driver) product_screen_2.scroll_header("left") date_str_2 = product_screen_2.get_all_header_fields()["Release Date"] self.press_back_button() self.scroll_page(direction="up") # Parse string to datetime objects date_format = "%d %b %Y" date_1 = datetime.strptime(date_str_1, date_format) date_2 = datetime.strptime(date_str_2, date_format) assert date_1 > date_2, ( f"Expected first product to be newer than last product, " f"but got:\nFirst: {date_str_1}\nLast: {date_str_2}" ) def change_sorting_option(self, sorting_option, period=None): try: self.click_with_retry(5, By.XPATH, self.streams_sorting_btn) except (NoSuchElementException, TimeoutException): self.waiter(3, By.ACCESSIBILITY_ID, self.release_date_sorting_btn).click() sorting_option_btns = \ {'Release Date': '//android.view.ViewGroup[@resource-id="release_date"]', 'Streams': '//android.view.ViewGroup[@resource-id="content"]/' 'android.view.ViewGroup[contains(@content-desc, "Streams")]'} streams_periods = \ {'1': 'Streams • 1 Days', '7': 'Streams • 7 Days', '28': 'Streams • 28 Days', 'All': 'Streams • All Time'} if sorting_option in sorting_option_btns: # Temporary delay to prevent taps from being # intercepted by the bottom navigation. sleep(1) self.click_with_retry(10, By.XPATH, sorting_option_btns[sorting_option]) if sorting_option == 'Streams': if period not in streams_periods: raise ValueError(f"Invalid period for Streams: {period}") self.click_with_retry(10, By.ACCESSIBILITY_ID, streams_periods[period]) else: raise ValueError(f'No such sorting option: {sorting_option}') def assert_displayed_sorting_option(self, sorting_option): locator = text_view_xpath(sorting_option) self.waiter(10, By.XPATH, locator) def assert_no_items_error_msg(self, items_type): no_items_titles = { 'No songs': self.no_songs_msg_title, 'No products': self.no_products_msg_title, 'No artists': self.no_artists_msg_title } assert self.waiter(20, By.XPATH, no_items_titles[items_type]) assert self.waiter(1, By.XPATH, self.no_items_msg_body) assert self.waiter(1, By.ACCESSIBILITY_ID, self.no_items_reset_btn) def reset_filters(self): self.click_with_retry(10, By.ACCESSIBILITY_ID, self.no_items_reset_btn) def reset_filters_via_panel(self): self.click_with_retry(10, By.XPATH, self.filter_btn['android']) self.click_with_retry(10, By.XPATH, resource_id_xpath('resetButton')) self.click_with_retry(10, By.XPATH, self.apply_btn) def assert_no_artists_displayed(self): self.waiter(10, By.XPATH, self.no_artists_title) self.waiter(3, By.XPATH, self.no_artists_content) self.waiter(3, By.XPATH, self.no_artists_reset_btn) @retry_on_assertion(max_retries=2, delay=3) def assert_number_of_displayed_artists_exceeds(self, number): actual_num_of_artists = ( len(self.waiter(20, By.XPATH, self.artist_name, multiple=True))) assert actual_num_of_artists > int(number), \ (f"Expected more than {number} artists, " f"but found {actual_num_of_artists}") @retry_on_assertion(max_retries=2, delay=3) def assert_number_of_displayed_products_exceeds(self, number): actual_num_of_products = ( len(self.waiter(30, By.XPATH, self.product_name, multiple=True))) assert actual_num_of_products > int(number), \ (f"Expected more than {number} products, " f"but found {actual_num_of_products}") def _assert_cards_consistency(self, label, main_locator, element_locators): """ Generic method to assert consistency of UI card components. :param label: Descriptive label for logs (e.g., 'Product', 'Song') :param main_locator: Tuple (By, locator) used to wait for list presence :param element_locators: List of tuples [(label, (By, locator)), ...] """ # Wait for main card list to load self.waiter(20, *main_locator) # Fetch all grouped elements element_groups = [] group_labels = [] for label_text, (strategy, locator) in element_locators: elems = self.waiter(10 if 'order' in locator else 5, strategy, locator, multiple=True) element_groups.append(elems) group_labels.append(label_text) max_length = max(len(group) for group in element_groups) - 1 element_groups = [group[:max_length] for group in element_groups] lengths = list(map(len, element_groups)) assert len(set(lengths)) == 1, ( f"Inconsistent {label.lower()} card data: " + ', '.join( f"{lbl}={l}" for lbl, l in zip(group_labels, lengths)) ) def assert_products_cards(self): self._assert_cards_consistency( label="Product", main_locator=(By.XPATH, self.product_name), element_locators=[ ("orders", (By.XPATH, self.product_order)), ("icons", (By.XPATH, self.product_icon)), ("product names", (By.XPATH, self.product_name)), ("artist names", (By.XPATH, self.product_artist_name)), ("counts", (By.XPATH, self.product_stream_count)), ] ) def assert_songs_cards(self): self._assert_cards_consistency( label="Song", main_locator=(By.XPATH, self.song_name), element_locators=[ ("orders", (By.XPATH, self.song_order)), ("covers", (By.XPATH, self.song_cover)), ("song names", (By.XPATH, self.song_name)), ("artist names", (By.XPATH, self.song_artist_name)), ("counts", (By.XPATH, self.song_stream_count)), ] ) def assert_artists_cards(self): self._assert_cards_consistency( label="Artist", main_locator=(By.XPATH, self.artist_name), element_locators=[ ("orders", (By.XPATH, self.artist_order)), ("covers", (By.XPATH, self.artist_cover)), ("names", (By.XPATH, self.artist_name)), ("counts", (By.XPATH, self.artist_stream_count)), ] ) def open_profile_screen(self, context): self.waiter(5, By.XPATH, self.account_tab).click() self.waiter(20, By.XPATH, context.profile_screen.profile_detail) def star_another_product_from_my_catalog(self, context): product_elements = ( self.waiter(20, By.XPATH, resource_id_xpath('productName'), multiple=True)) for product_element in product_elements: product_name = product_element.text.strip() if product_name in context.starred_product_names: continue self.click_with_retry(5, element=product_element) self.waiter(20, By.XPATH, '//*[@resource-id="headerScrollView"]' '//*[@resource-id="favoriteButton"]').click() context.starred_product_names.append(product_name) context.my_catalog_starred_product_name = product_name sleep(3) return raise AssertionError( "Could not find a unique visible product in My Catalog. " f"Already selected products: {context.starred_product_names}" ) def star_another_artist_from_my_catalog(self, context): artist_elements = self.waiter( 20, By.XPATH, resource_id_xpath('participantName'), multiple=True, ) for artist_element in artist_elements: artist_name = artist_element.text.strip() if artist_name in context.starred_artist_names: continue self.click_with_retry(5, element=artist_element) self.waiter(20, By.XPATH, resource_id_xpath('favoriteButton')).click() self.waiter(5, By.XPATH, self.active_item_path) context.starred_artist_names.append(artist_name) context.first_starred_artist_name = artist_name sleep(3) return raise AssertionError( "Could not find a unique visible artist in My Catalog. " f"Already selected artists: {context.starred_artist_names}" ) def open_product_starred_from_my_catalog(self, context): product_name = context.my_catalog_starred_product_name product_name_xpath = xpath_literal(product_name) product_loc = ( '//android.widget.TextView[@resource-id="productName" ' f'and @text={product_name_xpath}]' ) self.click_with_retry(10, By.XPATH, product_loc)