Skip to content

Commit c412fcb

Browse files
test: Add US05 tests for required grade calculations and approval scenarios
1 parent 4da0352 commit c412fcb

File tree

2 files changed

+344
-19
lines changed

2 files changed

+344
-19
lines changed

.github/workflows/python-selenium-ci-workflow.yaml

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -99,29 +99,33 @@ jobs:
9999
run: |
100100
# Check if tests directory exists, if not create it
101101
mkdir -p tests
102-
cd tests
103-
python -m pip install --upgrade pip
104-
# Create requirements.txt with necessary packages
105-
echo -e "pytest\nselenium\nwebdriver-manager\npytest-html" > requirements.txt
106-
pip install -r requirements.txt
102+
# Check if requirements.txt exists, if not create it
103+
if [ ! -f tests/requirements.txt ]; then
104+
echo "selenium" > tests/requirements.txt
105+
fi
106+
pip install -r tests/requirements.txt
107107
108108
- name: Run Selenium tests
109109
run: |
110-
# Create screenshots directory if it doesn't exist
111-
mkdir -p tests/screenshots
112-
113110
cd tests
114-
python -m pytest -v --html=report.html us1.py us2.py us3.py us4.py
115-
env:
116-
APP_URL: 'http://localhost:3000'
117-
PYTHONPATH: ${{ github.workspace }}
111+
pytest us1.py us2.py us3.py us4.py us5.py # Added us5.py
118112
119-
- name: Upload test results
120-
if: always()
113+
- name: Upload screenshots artifact (on failure)
114+
if: failure()
121115
uses: actions/upload-artifact@v4
122116
with:
123-
name: test-results
124-
path: |
125-
tests/screenshots/
126-
tests/report.html
127-
retention-days: 5
117+
name: selenium-test-screenshots
118+
path: tests/screenshots/
119+
retention-days: 7
120+
121+
- name: Stop React server
122+
if: always() # Ensure server is stopped even if tests fail
123+
run: |
124+
# Find and kill the process listening on port 3000
125+
PID=$(lsof -t -i:3000)
126+
if [ -n "$PID" ]; then
127+
kill $PID
128+
echo "React server stopped."
129+
else
130+
echo "React server not found or already stopped."
131+
fi

tests/us5.py

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
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

Comments
 (0)