|
| 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 US05Tests(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 | + # Selectors for US05 - specific to the result page |
| 27 | + REQUIRED_GRADE_DISPLAY_SELECTOR = "p.result__card-needed" # Hypothetical, needs verification |
| 28 | + # For "impossible to approve" or "already approved" messages, it might be the same element |
| 29 | + # or a different status message area. Assuming it's the same for now. |
| 30 | + |
| 31 | + RESULT_PAGE_CONTAINER_SELECTOR = "div.result" |
| 32 | + FIRST_TIME_ALERT_BUTTON_SELECTOR = ".alert__button.alert__button--single" |
| 33 | + ALERT_OVERLAY_SELECTOR = "div.alert__overlay" |
| 34 | + NAV_BACK_BUTTON_XPATH = "//button[contains(@class, 'nav-bar__button') and .//span[contains(@class, 'back-icon')]/svg[contains(@class, 'lucide-chevron-left')]]" |
| 35 | + HOME_CONTAINER_SELECTOR = "div.home__container" |
| 36 | + SETTINGS_NAV_BUTTON_XPATH = "//button[contains(@class, 'nav-bar__button') and .//span[contains(@class, 'settings-icon')]/svg[contains(@class, 'lucide-settings')]]" |
| 37 | + APPROVAL_GRADE_INPUT_SELECTOR = "input.settings__input[type='number']" # Hypothetical for settings page |
| 38 | + |
| 39 | + def set_driver_fixture(self, driver): |
| 40 | + self.driver = driver |
| 41 | + self.wait_short = WebDriverWait(self.driver, 5) |
| 42 | + self.wait_long = WebDriverWait(self.driver, 15) |
| 43 | + if not os.path.exists("screenshots"): |
| 44 | + os.makedirs("screenshots") |
| 45 | + |
| 46 | + def setUp(self): |
| 47 | + if not hasattr(self, 'driver') or not self.driver: |
| 48 | + logger.info("WebDriver not set by fixture, attempting fallback setup for direct unittest execution.") |
| 49 | + try: |
| 50 | + options = webdriver.ChromeOptions() |
| 51 | + # options.add_argument('--headless') |
| 52 | + # options.add_argument('--disable-gpu') |
| 53 | + # options.add_argument('--window-size=1920,1080') |
| 54 | + self.driver = webdriver.Chrome(options=options) |
| 55 | + self.is_driver_managed_by_fallback = True |
| 56 | + except Exception as e: |
| 57 | + logger.error(f"Failed to initialize fallback WebDriver: {e}") |
| 58 | + self.fail(f"Failed to initialize fallback WebDriver: {e}") |
| 59 | + else: |
| 60 | + self.is_driver_managed_by_fallback = False |
| 61 | + |
| 62 | + if hasattr(self, 'driver') and self.driver: |
| 63 | + self.set_driver_fixture(self.driver) |
| 64 | + else: |
| 65 | + logger.error("Driver is not initialized after setup attempt.") |
| 66 | + self.fail("Driver could not be initialized.") |
| 67 | + return |
| 68 | + self._initial_setup() |
| 69 | + |
| 70 | + def tearDown(self): |
| 71 | + if hasattr(self, 'is_driver_managed_by_fallback') and self.is_driver_managed_by_fallback: |
| 72 | + if self.driver: |
| 73 | + self.driver.quit() |
| 74 | + # For pytest-managed driver, teardown is handled by the fixture |
| 75 | + |
| 76 | + def _take_screenshot(self, name_suffix): |
| 77 | + timestamp = int(time.time()) |
| 78 | + test_method_name = getattr(self, '_testMethodName', 'unknown_test') |
| 79 | + screenshot_name = f"screenshots/{test_method_name}_{name_suffix}_{timestamp}.png" |
| 80 | + try: |
| 81 | + if hasattr(self, 'driver') and self.driver: |
| 82 | + self.driver.save_screenshot(screenshot_name) |
| 83 | + logger.info(f"Screenshot saved: {screenshot_name}") |
| 84 | + except Exception as e: |
| 85 | + logger.error(f"Error saving screenshot {screenshot_name}: {e}") |
| 86 | + |
| 87 | + def _initial_setup(self): |
| 88 | + self.driver.get(self.BASE_URL) |
| 89 | + logger.info(f"Navigated to base URL: {self.BASE_URL}") |
| 90 | + try: |
| 91 | + alert_button = WebDriverWait(self.driver, 10).until( |
| 92 | + EC.element_to_be_clickable((By.CSS_SELECTOR, self.FIRST_TIME_ALERT_BUTTON_SELECTOR)) |
| 93 | + ) |
| 94 | + alert_button.click() |
| 95 | + WebDriverWait(self.driver, 5).until( |
| 96 | + EC.invisibility_of_element_located((By.CSS_SELECTOR, self.ALERT_OVERLAY_SELECTOR)) |
| 97 | + ) |
| 98 | + nav_back_button = WebDriverWait(self.driver, 10).until( |
| 99 | + EC.element_to_be_clickable((By.XPATH, self.NAV_BACK_BUTTON_XPATH)) |
| 100 | + ) |
| 101 | + nav_back_button.click() |
| 102 | + WebDriverWait(self.driver, 10).until( |
| 103 | + EC.presence_of_element_located((By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR)) |
| 104 | + ) |
| 105 | + logger.info("Successfully navigated back to the Home page after initial alert.") |
| 106 | + except TimeoutException: |
| 107 | + logger.info("First-time user alert or subsequent navigation elements not found. Assuming already on Home page or alert handled.") |
| 108 | + try: |
| 109 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR))) |
| 110 | + logger.info("Confirmed on Home page.") |
| 111 | + except TimeoutException: |
| 112 | + logger.error(f"Failed to ensure presence on Home page. Current URL: {self.driver.current_url}") |
| 113 | + self._take_screenshot("initial_setup_home_fallback_failed") |
| 114 | + self.fail("Could not ensure presence on the Home page during initial setup.") |
| 115 | + except Exception as e: |
| 116 | + logger.error(f"An unexpected error occurred during initial setup: {e}", exc_info=True) |
| 117 | + self._take_screenshot("initial_setup_error") |
| 118 | + self.fail(f"Unexpected error during initial setup: {e}") |
| 119 | + |
| 120 | + # Ensure approval grade is 3.0 (default) |
| 121 | + self._set_approval_grade("3.0") |
| 122 | + |
| 123 | + |
| 124 | + def _set_approval_grade(self, approval_grade_value): |
| 125 | + logger.info(f"Setting approval grade to: {approval_grade_value}") |
| 126 | + # Navigate to settings if not already there or on home |
| 127 | + current_url = self.driver.current_url |
| 128 | + on_settings_page = False |
| 129 | + try: |
| 130 | + if self.driver.find_element(By.CSS_SELECTOR, self.APPROVAL_GRADE_INPUT_SELECTOR).is_displayed(): |
| 131 | + on_settings_page = True |
| 132 | + except NoSuchElementException: # Not on settings |
| 133 | + pass |
| 134 | + |
| 135 | + if not on_settings_page: |
| 136 | + # If on result page, go back home first |
| 137 | + try: |
| 138 | + if self.driver.find_element(By.CSS_SELECTOR, self.RESULT_PAGE_CONTAINER_SELECTOR).is_displayed(): |
| 139 | + self.driver.find_element(By.XPATH, self.NAV_BACK_BUTTON_XPATH).click() |
| 140 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR))) |
| 141 | + except NoSuchElementException: |
| 142 | + pass # Not on result page, or already home |
| 143 | + |
| 144 | + # Now on home (or was already), navigate to settings |
| 145 | + self.wait_long.until(EC.element_to_be_clickable((By.XPATH, self.SETTINGS_NAV_BUTTON_XPATH))).click() |
| 146 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.APPROVAL_GRADE_INPUT_SELECTOR))) |
| 147 | + logger.info("Navigated to Settings page.") |
| 148 | + |
| 149 | + approval_input = self.wait_long.until(EC.visibility_of_element_located((By.CSS_SELECTOR, self.APPROVAL_GRADE_INPUT_SELECTOR))) |
| 150 | + approval_input.clear() |
| 151 | + approval_input.send_keys(str(approval_grade_value)) |
| 152 | + logger.info(f"Set approval grade input to {approval_grade_value}.") |
| 153 | + |
| 154 | + # Navigate back to Home page |
| 155 | + self.driver.find_element(By.XPATH, self.NAV_BACK_BUTTON_XPATH).click() |
| 156 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR))) |
| 157 | + logger.info("Navigated back to Home page from Settings.") |
| 158 | + |
| 159 | + |
| 160 | + def _add_grade_and_percentage(self, grade, percentage): |
| 161 | + on_result_page = False |
| 162 | + try: |
| 163 | + if self.driver.find_element(By.CSS_SELECTOR, self.RESULT_PAGE_CONTAINER_SELECTOR).is_displayed(): |
| 164 | + on_result_page = True |
| 165 | + except NoSuchElementException: |
| 166 | + on_result_page = False |
| 167 | + |
| 168 | + if on_result_page: |
| 169 | + logger.info("On result page, navigating back to home to add grades.") |
| 170 | + self.driver.find_element(By.XPATH, self.NAV_BACK_BUTTON_XPATH).click() |
| 171 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.HOME_CONTAINER_SELECTOR))) |
| 172 | + |
| 173 | + grade_rows = self.driver.find_elements(By.CSS_SELECTOR, self.GRADES_LIST_ITEM_SELECTOR) |
| 174 | + if not grade_rows: |
| 175 | + self.driver.find_element(By.CSS_SELECTOR, self.ADD_GRADE_BUTTON_SELECTOR).click() |
| 176 | + time.sleep(0.5) |
| 177 | + grade_rows = self.driver.find_elements(By.CSS_SELECTOR, self.GRADES_LIST_ITEM_SELECTOR) |
| 178 | + if not grade_rows: self.fail("Failed to add initial grade row.") |
| 179 | + |
| 180 | + last_row = grade_rows[-1] |
| 181 | + grade_input_element = last_row.find_element(By.CSS_SELECTOR, self.GRADE_INPUT_SELECTOR) |
| 182 | + percentage_input_element = last_row.find_element(By.CSS_SELECTOR, self.PERCENTAGE_INPUT_SELECTOR) |
| 183 | + |
| 184 | + grade_input_element.clear() |
| 185 | + grade_input_element.send_keys(str(grade)) |
| 186 | + percentage_input_element.clear() |
| 187 | + percentage_input_element.send_keys(str(percentage)) |
| 188 | + |
| 189 | + self.driver.find_element(By.CSS_SELECTOR, self.ADD_GRADE_BUTTON_SELECTOR).click() |
| 190 | + time.sleep(0.5) |
| 191 | + logger.info(f"Added grade: {grade}, percentage: {percentage}.") |
| 192 | + |
| 193 | + def _get_required_grade_or_message(self): |
| 194 | + raw_text = "" |
| 195 | + try: |
| 196 | + # Ensure on result page |
| 197 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.RESULT_PAGE_CONTAINER_SELECTOR))) |
| 198 | + |
| 199 | + display_element = self.wait_long.until( |
| 200 | + EC.visibility_of_element_located((By.CSS_SELECTOR, self.REQUIRED_GRADE_DISPLAY_SELECTOR)) |
| 201 | + ) |
| 202 | + time.sleep(0.2) # Allow text to stabilize |
| 203 | + raw_text = display_element.text.strip() |
| 204 | + logger.info(f"Raw required grade/message text: '{raw_text}'") |
| 205 | + |
| 206 | + if not raw_text: |
| 207 | + logger.warning("Required grade/message text is empty.") |
| 208 | + self._take_screenshot("empty_required_grade_message") |
| 209 | + return "Error: Empty Value" |
| 210 | + |
| 211 | + # Check for specific messages first |
| 212 | + if "No es posible aprobar la materia" in raw_text: |
| 213 | + return "No es posible aprobar" # Standardized message |
| 214 | + if "Ya se ha aprobado la materia" in raw_text: |
| 215 | + return "Ya se ha aprobado la materia" |
| 216 | + # Try to parse a grade |
| 217 | + # Example text: "Necesitas un 4.0 en el 50% restante para aprobar la materia con un 3.0" |
| 218 | + match = re.search(r"Necesitas un (\d+\.?\d*|\.\d+) en el", raw_text) |
| 219 | + if match: |
| 220 | + grade_str = match.group(1) |
| 221 | + logger.info(f"Extracted required grade string: '{grade_str}'") |
| 222 | + return float(grade_str) |
| 223 | + else: |
| 224 | + logger.error(f"Could not parse required grade from text: '{raw_text}'") |
| 225 | + self._take_screenshot("required_grade_parse_error") |
| 226 | + return "Error: Parse" |
| 227 | + except TimeoutException: |
| 228 | + logger.error(f"Timeout waiting for required grade display: {self.REQUIRED_GRADE_DISPLAY_SELECTOR}") |
| 229 | + self._take_screenshot("required_grade_timeout") |
| 230 | + return "Error: Timeout" |
| 231 | + except ValueError: |
| 232 | + logger.error(f"Could not convert extracted grade string to float from '{raw_text}'.") |
| 233 | + self._take_screenshot("required_grade_value_error") |
| 234 | + return "Error: Conversion" |
| 235 | + except Exception as e: |
| 236 | + logger.error(f"Error getting required grade/message: {e}", exc_info=True) |
| 237 | + self._take_screenshot("get_required_grade_error") |
| 238 | + return "Error: General" |
| 239 | + |
| 240 | + def _click_calculate_and_wait_for_result_page(self): |
| 241 | + calculate_button = self.wait_long.until(EC.element_to_be_clickable((By.CSS_SELECTOR, self.CALCULATE_BUTTON_SELECTOR))) |
| 242 | + calculate_button.click() |
| 243 | + self.wait_long.until(EC.presence_of_element_located((By.CSS_SELECTOR, self.RESULT_PAGE_CONTAINER_SELECTOR))) |
| 244 | + logger.info("Clicked 'Calcular' and result page container is present.") |
| 245 | + |
| 246 | + # --- Test Cases for US05 --- |
| 247 | + |
| 248 | + def test_us05_calculate_required_grade_for_approval(self): |
| 249 | + # Corresponds to Task 5.1 |
| 250 | + # Approval grade default 3.0 |
| 251 | + # Add grades: Grade: "2.0", Percentage: "50%". |
| 252 | + # Expected: Nota Necesaria = 4.0 |
| 253 | + test_name = self._testMethodName |
| 254 | + logger.info(f"Running test: {test_name}") |
| 255 | + try: |
| 256 | + self._add_grade_and_percentage("2.0", "50") |
| 257 | + self._click_calculate_and_wait_for_result_page() |
| 258 | + |
| 259 | + required_grade = self._get_required_grade_or_message() |
| 260 | + self.assertEqual(required_grade, 4.0, f"Test {test_name}: Expected 4.0, got {required_grade}") |
| 261 | + logger.info(f"Test {test_name} passed. Required grade: {required_grade}") |
| 262 | + |
| 263 | + except AssertionError as e: |
| 264 | + logger.error(f"AssertionError in {test_name}: {e}", exc_info=True) |
| 265 | + self._take_screenshot(f"{test_name}_assertion_error") |
| 266 | + self.fail(f"AssertionError in {test_name}: {e}") |
| 267 | + except Exception as e: |
| 268 | + logger.error(f"Exception in {test_name}: {e}", exc_info=True) |
| 269 | + self._take_screenshot(f"{test_name}_exception") |
| 270 | + self.fail(f"Exception in {test_name}: {e}") |
| 271 | + |
| 272 | + def test_us05_impossible_to_approve_scenario(self): |
| 273 | + # Corresponds to Task 5.2 |
| 274 | + # Approval grade default 3.0 |
| 275 | + # Add grades: Grade: "1.0", Percentage: "80%". |
| 276 | + # Expected: "No es posible aprobar" |
| 277 | + test_name = self._testMethodName |
| 278 | + logger.info(f"Running test: {test_name}") |
| 279 | + try: |
| 280 | + self._add_grade_and_percentage("1.0", "80") |
| 281 | + self._click_calculate_and_wait_for_result_page() |
| 282 | + |
| 283 | + message = self._get_required_grade_or_message() |
| 284 | + self.assertEqual(message, "No es posible aprobar", f"Test {test_name}: Expected 'No es posible aprobar', got '{message}'") |
| 285 | + logger.info(f"Test {test_name} passed. Message: {message}") |
| 286 | + |
| 287 | + except AssertionError as e: |
| 288 | + logger.error(f"AssertionError in {test_name}: {e}", exc_info=True) |
| 289 | + self._take_screenshot(f"{test_name}_assertion_error") |
| 290 | + self.fail(f"AssertionError in {test_name}: {e}") |
| 291 | + except Exception as e: |
| 292 | + logger.error(f"Exception in {test_name}: {e}", exc_info=True) |
| 293 | + self._take_screenshot(f"{test_name}_exception") |
| 294 | + self.fail(f"Exception in {test_name}: {e}") |
| 295 | + |
| 296 | + def test_us05_already_approved_scenario(self): |
| 297 | + # Corresponds to Task 5.3 |
| 298 | + # Approval grade default 3.0 |
| 299 | + # Add grades: Grade: "4.0", Percentage: "80%". |
| 300 | + # Expected: "Ya se ha aprobado la materia" |
| 301 | + test_name = self._testMethodName |
| 302 | + logger.info(f"Running test: {test_name}") |
| 303 | + try: |
| 304 | + self._add_grade_and_percentage("4.0", "80") |
| 305 | + self._click_calculate_and_wait_for_result_page() |
| 306 | + |
| 307 | + message = self._get_required_grade_or_message() |
| 308 | + self.assertEqual(message, "Ya se ha aprobado la materia", f"Test {test_name}: Expected 'Ya se ha aprobado la materia', got '{message}'") |
| 309 | + logger.info(f"Test {test_name} passed. Message: {message}") |
| 310 | + |
| 311 | + except AssertionError as e: |
| 312 | + logger.error(f"AssertionError in {test_name}: {e}", exc_info=True) |
| 313 | + self._take_screenshot(f"{test_name}_assertion_error") |
| 314 | + self.fail(f"AssertionError in {test_name}: {e}") |
| 315 | + except Exception as e: |
| 316 | + logger.error(f"Exception in {test_name}: {e}", exc_info=True) |
| 317 | + self._take_screenshot(f"{test_name}_exception") |
| 318 | + self.fail(f"Exception in {test_name}: {e}") |
| 319 | + |
| 320 | +if __name__ == '__main__': |
| 321 | + unittest.main(verbosity=2) |
0 commit comments