Files
padelnomics/web/tests/test_visual.py
Deeman 777333e918 refactor(tests): overhaul visual tests — single server, mock emails, fix init_db
- Consolidate 3 duplicate server processes into 1 session-scoped
  live_server fixture in conftest.py (port 5111, shared across all
  visual test modules). Reduces startup overhead from ~3× to 1×.

- Fix init_db mock: patch padelnomics.app.init_db (where it's used)
  instead of core.init_db (where it's defined). The before_serving
  hook imported init_db locally — patching core alone didn't prevent
  the real init_db from replacing the in-memory test DB.

- Keep patches active through app.run_task() so before_serving hooks
  can't replace the test DB during the server's lifetime.

- Force RESEND_API_KEY="" in the visual test server subprocess to
  prevent real email sends (dev mode: prints to stdout, returns "dev").

- Remove 4 screenshot-only no-op tests, replace with single
  test_capture_screenshots that grabs all pages in one pass.

- Fix test_planner_tab_switching: remove nonexistent "metrics" tab.

- Delete ~200 lines of duplicated boilerplate from 3 test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:40:11 +01:00

245 lines
8.5 KiB
Python

"""
Visual regression tests using Playwright.
Takes screenshots of key pages and verifies styling invariants
(heading colors, backgrounds, nav layout, logo presence).
Skipped by default (requires `playwright install chromium`).
Run explicitly with:
uv run pytest -m visual tests/test_visual.py -v
Screenshots are saved to tests/screenshots/ for manual review.
"""
import re
from pathlib import Path
import pytest
from playwright.sync_api import expect
pytestmark = pytest.mark.visual
SCREENSHOTS_DIR = Path(__file__).parent / "screenshots"
SCREENSHOTS_DIR.mkdir(exist_ok=True)
def parse_rgb(color_str):
"""Parse rgb(r,g,b) or rgba(r,g,b,a) into [r, g, b]."""
nums = re.findall(r"[\d.]+", color_str)
return [int(float(x)) for x in nums[:3]]
@pytest.fixture
def page(browser):
"""Desktop page with dark OS preference to catch theme leaks."""
pg = browser.new_page(
viewport={"width": 1280, "height": 900},
color_scheme="dark",
)
yield pg
pg.close()
# ── Landing page tests ──────────────────────────────────────
def test_landing_light_background(live_server, page):
"""Verify the page has a light background, not dark."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
bg_color = page.evaluate("""
(() => {
const html_bg = getComputedStyle(document.documentElement).backgroundColor;
const body_bg = getComputedStyle(document.body).backgroundColor;
if (body_bg && !body_bg.includes('0, 0, 0, 0')) return body_bg;
return html_bg;
})()
""")
rgb = parse_rgb(bg_color)
brightness = sum(rgb) / 3
assert brightness > 200, f"Background too dark: {bg_color} (brightness={brightness})"
def test_landing_heading_colors(live_server, page):
"""Verify headings are readable (dark on light, white on dark hero)."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
# H1 is intentionally white (#fff) on the dark hero — verify it has a color set.
h1_color = page.evaluate(
"getComputedStyle(document.querySelector('h1')).color"
)
rgb = parse_rgb(h1_color)
assert rgb != [0, 0, 0] or sum(rgb) > 0, f"H1 appears unset: {h1_color}"
# H2s on light sections should be dark; skip h2s inside dark containers.
h2_data = page.evaluate("""
Array.from(document.querySelectorAll('h2')).map(el => {
const inDark = el.closest('.hero-dark, .cta-card') !== null;
return {color: getComputedStyle(el).color, inDark};
})
""")
for i, item in enumerate(h2_data):
if item["inDark"]:
continue
rgb = parse_rgb(item["color"])
brightness = sum(rgb) / 3
assert brightness < 100, f"H2[{i}] too light: {item['color']} (brightness={brightness})"
# H3s on light sections should be dark
h3_data = page.evaluate("""
Array.from(document.querySelectorAll('h3')).map(el => {
const inDark = el.closest('.hero-dark, .cta-card') !== null;
return {color: getComputedStyle(el).color, inDark};
})
""")
for i, item in enumerate(h3_data):
if item["inDark"]:
continue
rgb = parse_rgb(item["color"])
brightness = sum(rgb) / 3
assert brightness < 150, f"H3[{i}] too light: {item['color']} (brightness={brightness})"
def test_landing_logo_present(live_server, page):
"""Verify the nav logo link is visible."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
logo = page.locator("nav a.nav-logo")
expect(logo).to_be_visible()
text = logo.inner_text()
assert len(text) > 0, "Nav logo link has no text"
def test_landing_nav_no_overlap(live_server, page):
"""Verify nav items don't overlap each other."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
boxes = page.evaluate("""
(() => {
const navDiv = document.querySelector('nav > div');
if (!navDiv) return [];
const items = navDiv.children;
return Array.from(items)
.map(el => {
const r = el.getBoundingClientRect();
return {top: r.top, bottom: r.bottom, left: r.left, right: r.right, width: r.width};
})
.filter(r => r.width > 0);
})()
""")
for i in range(len(boxes) - 1):
a, b = boxes[i], boxes[i + 1]
h_overlap = a["right"] - b["left"]
assert h_overlap < 10, (
f"Nav items {i} and {i+1} overlap horizontally by {h_overlap:.0f}px"
)
def test_landing_cards_have_colored_borders(live_server, page):
"""Verify landing page cards have a visible left border accent."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
border_widths = page.evaluate("""
Array.from(document.querySelectorAll('.card')).map(
el => parseFloat(getComputedStyle(el).borderLeftWidth)
)
""")
assert len(border_widths) > 0, "No .card elements found"
cards_with_accent = [w for w in border_widths if w >= 4]
assert len(cards_with_accent) >= 6, (
f"Expected >=6 cards with 4px left border, got {len(cards_with_accent)}"
)
def test_landing_logo_links_to_landing(live_server, page):
"""Verify nav-logo links to the landing page (language-prefixed or root)."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
href = page.locator("nav a.nav-logo").get_attribute("href")
assert href == "/" or (href.startswith("/") and href.endswith("/")), (
f"Nav logo href unexpected: {href}"
)
def test_landing_teaser_light_theme(live_server, page):
"""Verify the ROI calc card has a white/light background."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
teaser_bg = page.evaluate(
"getComputedStyle(document.querySelector('.roi-calc')).backgroundColor"
)
rgb = parse_rgb(teaser_bg)
brightness = sum(rgb) / 3
assert brightness > 240, f"ROI calc background too dark: {teaser_bg}"
def test_landing_no_dark_remnants(live_server, page):
"""Check that no major elements have dark backgrounds."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
dark_elements = page.evaluate("""
(() => {
const dark = [];
const allowedClasses = ['hero-dark', 'cta-card'];
const els = document.querySelectorAll('article, section, header, footer, main, div');
for (const el of els) {
const cls = el.className || '';
if (allowedClasses.some(c => cls.includes(c))) continue;
const bg = getComputedStyle(el).backgroundColor;
if (bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') continue;
const m = bg.match(/\\d+/g);
if (m) {
const [r, g, b] = m.map(Number);
const brightness = (r + g + b) / 3;
if (brightness < 50) {
dark.push({tag: el.tagName, class: cls, bg, brightness});
}
}
}
return dark;
})()
""")
assert len(dark_elements) == 0, (
f"Found {len(dark_elements)} unexpected dark-background elements: "
f"{dark_elements[:3]}"
)
# ── Screenshots (all pages in one pass) ─────────────────────
def test_capture_screenshots(live_server, browser):
"""Save reference screenshots for manual review (desktop + mobile + auth)."""
desktop = browser.new_page(viewport={"width": 1280, "height": 900})
mobile = browser.new_page(viewport={"width": 375, "height": 812})
# Landing desktop
desktop.goto(live_server)
desktop.wait_for_load_state("networkidle")
desktop.screenshot(path=str(SCREENSHOTS_DIR / "landing_full.png"), full_page=True)
# Auth pages
desktop.goto(f"{live_server}/auth/login")
desktop.wait_for_load_state("networkidle")
desktop.screenshot(path=str(SCREENSHOTS_DIR / "login.png"), full_page=True)
desktop.goto(f"{live_server}/auth/signup")
desktop.wait_for_load_state("networkidle")
desktop.screenshot(path=str(SCREENSHOTS_DIR / "signup.png"), full_page=True)
# Landing mobile
mobile.goto(live_server)
mobile.wait_for_load_state("networkidle")
mobile.screenshot(path=str(SCREENSHOTS_DIR / "landing_mobile.png"), full_page=True)
desktop.close()
mobile.close()