|
| 1 | +\ |
| 2 | +import unittest |
| 3 | +import os |
| 4 | +import time |
| 5 | +import re |
| 6 | +import logging |
| 7 | + |
| 8 | +from selenium import webdriver |
| 9 | +from selenium.webdriver.common.by import By |
| 10 | +from selenium.webdriver.support.ui import WebDriverWait |
| 11 | +from selenium.webdriver.support import expected_conditions as EC |
| 12 | +from selenium.common.exceptions import TimeoutException, NoSuchElementException |
| 13 | + |
| 14 | +# Configure logging |
| 15 | +logger = logging.getLogger(__name__) |
| 16 | +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
| 17 | + |
| 18 | +class US06Tests(unittest.TestCase): |
| 19 | + BASE_URL = "http://localhost:3000" |
| 20 | + GRADE_INPUT_SELECTOR = "input.home__input[placeholder='0.0'][type='number']" |
| 21 | + PERCENTAGE_INPUT_SELECTOR = "input.home__input[placeholder='0'][type='number']" |
| 22 | + ADD_GRADE_BUTTON_SELECTOR = "button.home__add-button" |
| 23 | + GRADES_LIST_ITEM_SELECTOR = "div.home__grades-container > div.home__grade-row" |
| 24 | + CALCULATE_BUTTON_SELECTOR = "button.home__calculate-button" |
| 25 | + |
| 26 | + RESULT_PAGE_CONTAINER_SELECTOR = "div.result" |
| 27 | + FIRST_TIME_ALERT_BUTTON_SELECTOR = ".alert__button.alert__button--single" |
| 28 | + ALERT_OVERLAY_SELECTOR = "div.alert__overlay" |
| 29 | + NAV_BACK_BUTTON_XPATH = "//button[contains(@class, 'nav-bar__button') and .//span[contains(@class, 'back-icon')]/svg[contains(@class, 'lucide-chevron-left')]]" |
| 30 | + NAV_BACK_BUTTON_SELECTOR = "nav.nav-bar > button.nav-bar__button:first-child" |
| 31 | + HOME_CONTAINER_SELECTOR = "div.home__container" |
| 32 | + |
| 33 | + # Selector for US06 - Final Estimated Status |
| 34 | + FINAL_STATUS_DISPLAY_SELECTOR = "#final-status-display" # Based on selenium-test-dev.md (id="final-status-display") |
| 35 | + |
| 36 | + # Selectors needed for setting up scenarios (from US05, though not directly tested here) |
| 37 | + REQUIRED_GRADE_DISPLAY_SELECTOR = "p.result__card-needed" |
| 38 | + SETTINGS_NAV_BUTTON_XPATH = "//button[contains(@class, 'nav-bar__button') and .//span[contains(@class, 'settings-icon')]/svg[contains(@class, 'lucide-settings')]]" |
| 39 | + APPROVAL_GRADE_INPUT_SELECTOR = "input.settings__input[type='number']" |
| 40 | + |
| 41 | + def set_driver_fixture(self, driver): |
| 42 | + self.driver = driver |
| 43 | + self.wait_short = WebDriverWait(self.driver, 5) |
| 44 | + self.wait_long = WebDriverWait(self.driver, 15) |
| 45 | + if not os.path.exists("screenshots"): |
| 46 | + os.makedirs("screenshots") |
| 47 | + |
| 48 | + def setUp(self): |
| 49 | + if not hasattr(self, 'driver') or not self.driver: |
| 50 | + logger.info("WebDriver not set by fixture, attempting fallback setup for direct unittest execution.") |
| 51 | + try: |
| 52 | + options = webdriver.ChromeOptions() |
| 53 | + # options.add_argument('--headless') |
| 54 | + # options.add_argument('--disable-gpu') |
| 55 | + self.driver = webdriver.Chrome(options=options) |
| 56 | + self.set_driver_fixture(self.driver) |
| 57 | + self.is_driver_managed_by_fallback = True |
| 58 | + logger.info("Fallback WebDriver initialized for direct unittest execution.") |
| 59 | + except Exception as e: |
| 60 | + logger.error(f"Failed to initialize fallback WebDriver: {e}") |
| 61 | + self.fail(f"Failed to initialize fallback WebDriver: {e}") |
| 62 | + else: |
| 63 | + logger.info("WebDriver already set, likely by a pytest fixture.") |
| 64 | + self.is_driver_managed_by_fallback = False |
| 65 | + self._initial_setup() |
| 66 | + |
| 67 | + def tearDown(self): |
| 68 | + if hasattr(self, 'is_driver_managed_by_fallback') and self.is_driver_managed_by_fallback: |
| 69 | + if self.driver: |
| 70 | + self.driver.quit() |
| 71 | + logger.info("Fallback WebDriver quit.") |
| 72 | + else: |
| 73 | + logger.info("Driver teardown managed by pytest fixture (if applicable).") |
| 74 | + |
| 75 | + def _take_screenshot(self, name_suffix): |
| 76 | + timestamp = int(time.time()) |
| 77 | + test_method_name = getattr(self, '_testMethodName', 'unknown_test') |
| 78 | + screenshot_name = f"screenshots/{test_method_name}_{name_suffix}_{timestamp}.png" |
| 79 | + try: |
| 80 | + if hasattr(self, 'driver') and self.driver: |
| 81 | + self.driver.save_screenshot(screenshot_name) |
| 82 | + logger.info(f"Screenshot saved: {screenshot_name}") |
| 83 | + except Exception as e: |
| 84 | + logger.error(f"Error saving screenshot {screenshot_name}: {e}") |
| 85 | + |
| 86 | + def _set_approval_grade(self, approval_grade_value): |
| 87 | + logger.info(f"Setting approval grade to: {approval_grade_value} using JavaScript injection") |
| 88 | + script = f""" |
| 89 | + localStorage.setItem('settings', JSON.stringify({{ |
| 90 | + minAcceptValue: parseFloat({approval_grade_value}), |
| 91 | + minValue: 0, |
| 92 | + maxValue: 5 |
| 93 | + }})); |
| 94 | + localStorage.setItem("isFirstTime", "false"); |
| 95 | + """ |
| 96 | + self.driver.execute_script(script) |
| 97 | + logger.info(f"Settings updated via JavaScript. Approval grade set to: {approval_grade_value}") |
| 98 | + self.driver.refresh() |
| 99 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR))) |
| 100 | + logger.info("Page refreshed after setting approval grade") |
| 101 | + |
| 102 | + def _initial_setup(self): |
| 103 | + if not hasattr(self, 'driver') or not self.driver: |
| 104 | + logger.error("Driver not initialized in _initial_setup. Aborting setup.") |
| 105 | + self.fail("Driver not initialized for test setup.") |
| 106 | + return |
| 107 | + |
| 108 | + self.driver.get(self.BASE_URL) |
| 109 | + logger.info(f"Navigated to base URL: {self.BASE_URL}") |
| 110 | + |
| 111 | + try: |
| 112 | + logger.info(f"Attempting to handle first-time alert with button '{self.FIRST_TIME_ALERT_BUTTON_SELECTOR}'.") |
| 113 | + alert_button = WebDriverWait(self.driver, 10).until( |
| 114 | + EC.element_to_be_clickable((By.CSS_SELECTOR, self.FIRST_TIME_ALERT_BUTTON_SELECTOR)) |
| 115 | + ) |
| 116 | + alert_button.click() |
| 117 | + logger.info(f"Clicked first-time alert button: '{self.FIRST_TIME_ALERT_BUTTON_SELECTOR}'.") |
| 118 | + |
| 119 | + WebDriverWait(self.driver, 5).until( |
| 120 | + EC.invisibility_of_element_located((By.CSS_SELECTOR, self.ALERT_OVERLAY_SELECTOR)) |
| 121 | + ) |
| 122 | + logger.info(f"Alert overlay '{self.ALERT_OVERLAY_SELECTOR}' is no longer visible. App should be on Settings page.") |
| 123 | + |
| 124 | + try: |
| 125 | + nav_back_button = WebDriverWait(self.driver, 5).until( |
| 126 | + EC.element_to_be_clickable((By.CSS_SELECTOR, self.NAV_BACK_BUTTON_SELECTOR)) |
| 127 | + ) |
| 128 | + logger.info("Found back button using CSS selector.") |
| 129 | + except TimeoutException: |
| 130 | + logger.info("CSS selector failed for back button, trying XPath...") |
| 131 | + nav_back_button = WebDriverWait(self.driver, 5).until( |
| 132 | + EC.element_to_be_clickable((By.XPATH, self.NAV_BACK_BUTTON_XPATH)) |
| 133 | + ) |
| 134 | + logger.info("Found back button using XPath selector.") |
| 135 | + |
| 136 | + nav_back_button.click() |
| 137 | + logger.info("Clicked nav back button to return to Home page.") |
| 138 | + |
| 139 | + WebDriverWait(self.driver, 10).until( |
| 140 | + EC.presence_of_element_located((By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR)) |
| 141 | + ) |
| 142 | + logger.info("Successfully navigated back to the Home page after initial alert.") |
| 143 | + |
| 144 | + except TimeoutException: |
| 145 | + logger.info("First-time user alert or subsequent navigation elements not found or timed out. Checking if already on Home page.") |
| 146 | + try: |
| 147 | + self.driver.find_element(By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR) |
| 148 | + logger.info("Already on the Home page or initial alert was not present.") |
| 149 | + except NoSuchElementException: |
| 150 | + logger.warning(f"Home container '{self.HOME_CONTAINER_SELECTOR}' not found. Attempting to re-navigate to BASE_URL.") |
| 151 | + self.driver.get(self.BASE_URL) |
| 152 | + try: |
| 153 | + WebDriverWait(self.driver, 10).until( |
| 154 | + EC.presence_of_element_located((By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR)) |
| 155 | + ) |
| 156 | + logger.info("Successfully navigated to Home page as a fallback.") |
| 157 | + except TimeoutException: |
| 158 | + logger.error("Failed to ensure presence on Home page even after fallback. Current URL: %s", self.driver.current_url) |
| 159 | + self._take_screenshot("initial_setup_home_fallback_failed") |
| 160 | + self.fail("Could not ensure presence on the Home page during initial setup.") |
| 161 | + except Exception as e: |
| 162 | + logger.error(f"An unexpected error occurred during initial setup: {e}", exc_info=True) |
| 163 | + self._take_screenshot("initial_setup_error") |
| 164 | + self.fail(f"Unexpected error during initial setup: {e}") |
| 165 | + |
| 166 | + self._set_approval_grade("3.0") # Default approval grade for tests |
| 167 | + |
| 168 | + def _add_grade_and_percentage(self, grade, percentage): |
| 169 | + if not hasattr(self, 'driver') or not self.driver: |
| 170 | + logger.error("Driver not available in _add_grade_and_percentage.") |
| 171 | + self.fail("Driver not available.") |
| 172 | + return |
| 173 | + |
| 174 | + on_result_page = False |
| 175 | + try: |
| 176 | + if self.driver.find_element(By.CSS_SELECTOR, self.RESULT_PAGE_CONTAINER_SELECTOR).is_displayed(): |
| 177 | + on_result_page = True |
| 178 | + except NoSuchElementException: |
| 179 | + on_result_page = False |
| 180 | + |
| 181 | + if on_result_page: |
| 182 | + logger.info("Currently on result page, navigating back to home to add grades.") |
| 183 | + try: |
| 184 | + nav_back_button = self.wait_short.until( |
| 185 | + EC.element_to_be_clickable((By.CSS_SELECTOR, self.NAV_BACK_BUTTON_SELECTOR)) |
| 186 | + ) |
| 187 | + except TimeoutException: |
| 188 | + try: |
| 189 | + nav_back_button = self.wait_long.until( |
| 190 | + EC.element_to_be_clickable((By.XPATH, self.NAV_BACK_BUTTON_XPATH)) |
| 191 | + ) |
| 192 | + except TimeoutException: |
| 193 | + nav_bar = self.wait_long.until( |
| 194 | + EC.presence_of_element_located((By.CSS_SELECTOR, "nav.nav-bar")) |
| 195 | + ) |
| 196 | + nav_buttons = nav_bar.find_elements(By.TAG_NAME, "button") |
| 197 | + if nav_buttons: |
| 198 | + nav_back_button = nav_buttons[0] |
| 199 | + else: |
| 200 | + self._take_screenshot("no_buttons_in_nav_bar_add_grade") |
| 201 | + self.fail("No buttons found in nav bar when trying to navigate back") |
| 202 | + nav_back_button.click() |
| 203 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR))) |
| 204 | + time.sleep(0.5) |
| 205 | + |
| 206 | + grade_rows = self.driver.find_elements(By.CSS_SELECTOR, self.GRADES_LIST_ITEM_SELECTOR) |
| 207 | + need_new_row = True |
| 208 | + if grade_rows: |
| 209 | + last_row = grade_rows[-1] |
| 210 | + try: |
| 211 | + grade_input = last_row.find_element(By.CSS_SELECTOR, self.GRADE_INPUT_SELECTOR) |
| 212 | + percentage_input = last_row.find_element(By.CSS_SELECTOR, self.PERCENTAGE_INPUT_SELECTOR) |
| 213 | + if not grade_input.get_attribute("value") and not percentage_input.get_attribute("value"): |
| 214 | + need_new_row = False |
| 215 | + except NoSuchElementException: |
| 216 | + need_new_row = True |
| 217 | + |
| 218 | + if need_new_row or not grade_rows: |
| 219 | + add_button = self.wait_long.until(EC.element_to_be_clickable((By.CSS_SELECTOR, self.ADD_GRADE_BUTTON_SELECTOR))) |
| 220 | + add_button.click() |
| 221 | + time.sleep(0.5) |
| 222 | + grade_rows = self.driver.find_elements(By.CSS_SELECTOR, self.GRADES_LIST_ITEM_SELECTOR) |
| 223 | + if not grade_rows: |
| 224 | + self._take_screenshot("failed_to_add_grade_row") |
| 225 | + self.fail("Failed to add grade row") |
| 226 | + |
| 227 | + last_row = grade_rows[-1] |
| 228 | + try: |
| 229 | + grade_input_element = last_row.find_element(By.CSS_SELECTOR, self.GRADE_INPUT_SELECTOR) |
| 230 | + percentage_input_element = last_row.find_element(By.CSS_SELECTOR, self.PERCENTAGE_INPUT_SELECTOR) |
| 231 | + |
| 232 | + grade_input_element.clear() |
| 233 | + grade_input_element.send_keys(str(grade)) |
| 234 | + percentage_input_element.clear() |
| 235 | + percentage_input_element.send_keys(str(percentage)) |
| 236 | + |
| 237 | + add_button = self.wait_long.until(EC.element_to_be_clickable((By.CSS_SELECTOR, self.ADD_GRADE_BUTTON_SELECTOR))) |
| 238 | + add_button.click() |
| 239 | + time.sleep(0.5) |
| 240 | + except NoSuchElementException as e: |
| 241 | + self._take_screenshot("input_elements_not_found") |
| 242 | + self.fail(f"Input elements not found: {e}") |
| 243 | + |
| 244 | + def _click_calculate_and_wait_for_result_page(self): |
| 245 | + calculate_button = self.wait_long.until(EC.element_to_be_clickable((By.CSS_SELECTOR, self.CALCULATE_BUTTON_SELECTOR))) |
| 246 | + calculate_button.click() |
| 247 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.RESULT_PAGE_CONTAINER_SELECTOR))) |
| 248 | + logger.info("Clicked 'Calcular' and result page container is present.") |
| 249 | + |
| 250 | + def _get_final_status_message(self): |
| 251 | + """Extracts and returns the final estimated status message from the result page.""" |
| 252 | + raw_text = "" |
| 253 | + try: |
| 254 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.RESULT_PAGE_CONTAINER_SELECTOR))) |
| 255 | + |
| 256 | + status_element = self.wait_long.until( |
| 257 | + EC.visibility_of_element_located((By.CSS_SELECTOR, self.FINAL_STATUS_DISPLAY_SELECTOR)) |
| 258 | + ) |
| 259 | + time.sleep(0.2) # Allow text to stabilize |
| 260 | + raw_text = status_element.text.strip() |
| 261 | + logger.info(f"Raw final status message text: '{raw_text}'") |
| 262 | + |
| 263 | + if not raw_text: |
| 264 | + logger.warning("Final status message text is empty.") |
| 265 | + self._take_screenshot("empty_final_status_message") |
| 266 | + return "Error: Empty Value" |
| 267 | + |
| 268 | + # Expected values: "Aprobado", "En riesgo", "No aprueba" |
| 269 | + return raw_text |
| 270 | + |
| 271 | + except TimeoutException: |
| 272 | + logger.error(f"Timeout waiting for final status display: {self.FINAL_STATUS_DISPLAY_SELECTOR}") |
| 273 | + self._take_screenshot("final_status_timeout") |
| 274 | + return "Error: Timeout" |
| 275 | + except Exception as e: |
| 276 | + logger.error(f"Error getting final status message: {e}", exc_info=True) |
| 277 | + self._take_screenshot("get_final_status_error") |
| 278 | + return "Error: General" |
| 279 | + |
| 280 | + # --- Test Cases for US06 Task 6.3 --- |
| 281 | + |
| 282 | + def test_us06_verify_final_status_aprobado(self): |
| 283 | + # Scenario "Aprobado": Input grades leading to an "already approved" state (Task 5.3). |
| 284 | + # Verify the status displays "Aprobado". |
| 285 | + # Grades: Grade: "4.0", Percentage: "80%". Approval grade: 3.0 |
| 286 | + test_name = self._testMethodName |
| 287 | + logger.info(f"Running test: {test_name}") |
| 288 | + try: |
| 289 | + self._add_grade_and_percentage("4.0", "80") |
| 290 | + self._click_calculate_and_wait_for_result_page() |
| 291 | + |
| 292 | + final_status = self._get_final_status_message() |
| 293 | + self.assertEqual(final_status, "Aprobado", f"Test {test_name}: Expected 'Aprobado', got '{final_status}'") |
| 294 | + logger.info(f"Test {test_name} passed. Final status: {final_status}") |
| 295 | + |
| 296 | + except AssertionError as e: |
| 297 | + logger.error(f"AssertionError in {test_name}: {e}", exc_info=True) |
| 298 | + self._take_screenshot(f"{test_name}_assertion_error") |
| 299 | + self.fail(f"AssertionError in {test_name}: {e}") |
| 300 | + except Exception as e: |
| 301 | + logger.error(f"Exception in {test_name}: {e}", exc_info=True) |
| 302 | + self._take_screenshot(f"{test_name}_exception") |
| 303 | + self.fail(f"Exception in {test_name}: {e}") |
| 304 | + |
| 305 | + def test_us06_verify_final_status_en_riesgo(self): |
| 306 | + # Scenario "En riesgo": Input grades leading to a "required grade" that is achievable (Task 5.1). |
| 307 | + # Verify the status displays "En riesgo". |
| 308 | + # Grades: Grade: "2.0", Percentage: "50%". Approval grade: 3.0. (Requires 4.0) |
| 309 | + test_name = self._testMethodName |
| 310 | + logger.info(f"Running test: {test_name}") |
| 311 | + try: |
| 312 | + self._add_grade_and_percentage("2.0", "50") |
| 313 | + self._click_calculate_and_wait_for_result_page() |
| 314 | + |
| 315 | + final_status = self._get_final_status_message() |
| 316 | + self.assertEqual(final_status, "En riesgo", f"Test {test_name}: Expected 'En riesgo', got '{final_status}'") |
| 317 | + logger.info(f"Test {test_name} passed. Final status: {final_status}") |
| 318 | + |
| 319 | + except AssertionError as e: |
| 320 | + logger.error(f"AssertionError in {test_name}: {e}", exc_info=True) |
| 321 | + self._take_screenshot(f"{test_name}_assertion_error") |
| 322 | + self.fail(f"AssertionError in {test_name}: {e}") |
| 323 | + except Exception as e: |
| 324 | + logger.error(f"Exception in {test_name}: {e}", exc_info=True) |
| 325 | + self._take_screenshot(f"{test_name}_exception") |
| 326 | + self.fail(f"Exception in {test_name}: {e}") |
| 327 | + |
| 328 | + def test_us06_verify_final_status_no_aprueba(self): |
| 329 | + # Scenario "No aprueba": Input grades leading to an "impossible to approve" state (Task 5.2). |
| 330 | + # Verify the status displays "No aprueba". |
| 331 | + # Grades: Grade: "1.0", Percentage: "80%". Approval grade: 3.0. (Requires 11.0) |
| 332 | + test_name = self._testMethodName |
| 333 | + logger.info(f"Running test: {test_name}") |
| 334 | + try: |
| 335 | + self._add_grade_and_percentage("1.0", "80") |
| 336 | + self._click_calculate_and_wait_for_result_page() |
| 337 | + |
| 338 | + final_status = self._get_final_status_message() |
| 339 | + self.assertEqual(final_status, "No aprueba", f"Test {test_name}: Expected 'No aprueba', got '{final_status}'") |
| 340 | + logger.info(f"Test {test_name} passed. Final status: {final_status}") |
| 341 | + |
| 342 | + except AssertionError as e: |
| 343 | + logger.error(f"AssertionError in {test_name}: {e}", exc_info=True) |
| 344 | + self._take_screenshot(f"{test_name}_assertion_error") |
| 345 | + self.fail(f"AssertionError in {test_name}: {e}") |
| 346 | + except Exception as e: |
| 347 | + logger.error(f"Exception in {test_name}: {e}", exc_info=True) |
| 348 | + self._take_screenshot(f"{test_name}_exception") |
| 349 | + self.fail(f"Exception in {test_name}: {e}") |
| 350 | + |
| 351 | +if __name__ == '__main__': |
| 352 | + unittest.main(verbosity=2) |
0 commit comments