Spaces:
Sleeping
Sleeping
| """Navigation and scroll-position tests. | |
| Verifies that switching between dashboard pages resets the scroll | |
| position to the top of the main content area. | |
| Usage: | |
| pytest tests/test_navigation.py -v | |
| (dashboard must be running at DASHBOARD_URL) | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import time | |
| import pytest | |
| from selenium import webdriver | |
| from selenium.webdriver.chrome.options import Options | |
| from selenium.webdriver.chrome.service import Service | |
| from selenium.webdriver.common.by import By | |
| from selenium.webdriver.support import expected_conditions as EC | |
| from selenium.webdriver.support.ui import WebDriverWait | |
| from webdriver_manager.chrome import ChromeDriverManager | |
| DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "http://localhost:8501") | |
| NAV_PAGES = ["Overview", "Drift Analysis", "Feature Insights", "Retraining Log", "Live Demo"] | |
| WAIT = 6 | |
| def _dashboard_reachable() -> bool: | |
| try: | |
| import urllib.request | |
| urllib.request.urlopen(DASHBOARD_URL, timeout=3) | |
| return True | |
| except Exception: | |
| return False | |
| def _make_driver() -> webdriver.Chrome: | |
| opts = Options() | |
| opts.add_argument("--headless=new") | |
| opts.add_argument("--no-sandbox") | |
| opts.add_argument("--disable-dev-shm-usage") | |
| opts.add_argument("--window-size=1600,1000") | |
| service = Service(ChromeDriverManager().install()) | |
| return webdriver.Chrome(service=service, options=opts) | |
| def _navigate_to(driver: webdriver.Chrome, page: str, wait: int = WAIT) -> None: | |
| """Click a sidebar navigation radio button and wait for the page to load.""" | |
| WebDriverWait(driver, 10).until( | |
| EC.presence_of_element_located((By.CSS_SELECTOR, "[data-testid='stSidebar']")) | |
| ) | |
| radios = driver.find_elements(By.CSS_SELECTOR, "[data-testid='stSidebar'] label") | |
| for label in radios: | |
| if label.text.strip() == page: | |
| label.click() | |
| time.sleep(wait) | |
| return | |
| raise AssertionError(f"Navigation label '{page}' not found in sidebar") | |
| def _main_scroll_top(driver: webdriver.Chrome) -> int: | |
| """Return the scrollTop of the main Streamlit content container.""" | |
| return driver.execute_script( | |
| "var el = document.querySelector('[data-testid=\"stMain\"]') " | |
| "|| document.querySelector('.main') " | |
| "|| document.querySelector('[data-testid=\"stAppViewContainer\"]');" | |
| "return el ? el.scrollTop : window.scrollY;" | |
| ) | |
| def _scroll_down(driver: webdriver.Chrome, px: int = 600) -> None: | |
| driver.execute_script( | |
| f"var el = document.querySelector('[data-testid=\"stMain\"]') " | |
| f"|| document.querySelector('.main') " | |
| f"|| document.querySelector('[data-testid=\"stAppViewContainer\"]');" | |
| f"if (el) {{ el.scrollTop = {px}; }} else {{ window.scrollBy(0, {px}); }}" | |
| ) | |
| time.sleep(0.5) | |
| def driver(): | |
| if not _dashboard_reachable(): | |
| pytest.skip(f"Dashboard not running at {DASHBOARD_URL}") | |
| drv = _make_driver() | |
| drv.get(DASHBOARD_URL) | |
| time.sleep(WAIT) | |
| yield drv | |
| drv.quit() | |
| class TestNavigation: | |
| def test_all_pages_load_without_error(self, driver): | |
| """Cycle through every page and confirm no Python traceback appears.""" | |
| for page in NAV_PAGES: | |
| _navigate_to(driver, page) | |
| body = driver.find_element(By.TAG_NAME, "body").text | |
| assert "Traceback (most recent call last)" not in body, ( | |
| f"Python traceback on page '{page}'" | |
| ) | |
| def test_page_content_changes_on_navigation(self, driver): | |
| """Each page must show its own title, not the previous page's title.""" | |
| _navigate_to(driver, "Live Demo") | |
| live_body = driver.find_element(By.TAG_NAME, "body").text | |
| assert "Live Demo" in live_body | |
| _navigate_to(driver, "Overview") | |
| overview_body = driver.find_element(By.TAG_NAME, "body").text | |
| assert "Rolling RMSE" in overview_body, ( | |
| "Overview content not visible after navigating from Live Demo" | |
| ) | |
| def test_scroll_resets_to_top_on_page_change(self, driver): | |
| """ | |
| Reproduce the reported bug: | |
| Navigate to Live Demo, scroll down, then go to Overview. | |
| The main content area must scroll back to y=0. | |
| """ | |
| _navigate_to(driver, "Live Demo") | |
| _scroll_down(driver, px=700) | |
| scroll_before = _main_scroll_top(driver) | |
| _navigate_to(driver, "Overview") | |
| scroll_after = _main_scroll_top(driver) | |
| assert scroll_after <= 50, ( | |
| f"BUG: page did not scroll to top after tab change. " | |
| f"scrollTop before={scroll_before}px, after={scroll_after}px. " | |
| f"User sees content from the old page position." | |
| ) | |
| def test_scroll_resets_across_all_page_transitions(self, driver): | |
| """Scroll down on every page, then switch — top must always reset.""" | |
| failures = [] | |
| pairs = [ | |
| ("Drift Analysis", "Feature Insights"), | |
| ("Feature Insights", "Retraining Log"), | |
| ("Retraining Log", "Live Demo"), | |
| ("Live Demo", "Overview"), | |
| ("Overview", "Drift Analysis"), | |
| ] | |
| for src, dst in pairs: | |
| _navigate_to(driver, src) | |
| _scroll_down(driver, px=500) | |
| _navigate_to(driver, dst) | |
| pos = _main_scroll_top(driver) | |
| if pos > 50: | |
| failures.append(f"{src} -> {dst}: scrollTop={pos}px (expected <=50)") | |
| assert not failures, ( | |
| "Scroll position did not reset on these transitions:\n" + | |
| "\n".join(failures) | |
| ) | |
| def test_screenshot_scroll_bug(self, driver): | |
| """Save before/after screenshots of the scroll bug for visual inspection.""" | |
| from pathlib import Path | |
| assets = Path(__file__).resolve().parent.parent / "assets" | |
| _navigate_to(driver, "Live Demo") | |
| _scroll_down(driver, px=700) | |
| driver.save_screenshot(str(assets / "nav_test_before.png")) | |
| _navigate_to(driver, "Overview") | |
| driver.save_screenshot(str(assets / "nav_test_after.png")) | |