Files
padelnomics/padelnomics/tests/test_visual.py
Deeman 6bba19f628 skip visual tests in CI — require explicit -m visual flag
Playwright visual tests need browser binaries (playwright install
chromium) which CI doesn't have. Mark them with pytest.mark.visual
and add addopts = "-m 'not visual'" so they're skipped by default.

Run locally with: uv run pytest -m visual tests/test_visual.py -v

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:59:53 +01:00

293 lines
9.7 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 asyncio
import multiprocessing
import time
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from playwright.sync_api import expect, sync_playwright
from padelnomics import core
from padelnomics.app import create_app
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]."""
import re
nums = re.findall(r"[\d.]+", color_str)
return [int(float(x)) for x in nums[:3]]
def _run_server(ready_event):
"""Run the Quart dev server in a separate process."""
import aiosqlite
async def _serve():
# Set up in-memory DB with schema
schema_path = (
Path(__file__).parent.parent
/ "src"
/ "padelnomics"
/ "migrations"
/ "schema.sql"
)
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys=ON")
await conn.executescript(schema_path.read_text())
await conn.commit()
core._db = conn
with patch.object(core, "init_db", new_callable=AsyncMock), \
patch.object(core, "close_db", new_callable=AsyncMock):
app = create_app()
app.config["TESTING"] = True
# Signal that the server is about to start
ready_event.set()
await app.run_task(host="127.0.0.1", port=5111)
asyncio.run(_serve())
@pytest.fixture(scope="module")
def live_server():
"""Start a live Quart server on port 5111 for Playwright tests."""
ready = multiprocessing.Event()
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
proc.start()
ready.wait(timeout=10)
# Give server a moment to bind
time.sleep(1)
yield "http://127.0.0.1:5111"
proc.terminate()
proc.join(timeout=5)
@pytest.fixture(scope="module")
def browser():
"""Launch a headless Chromium browser."""
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
yield b
b.close()
@pytest.fixture
def page(browser):
"""Create a new browser page with consistent viewport."""
pg = browser.new_page(viewport={"width": 1280, "height": 900})
yield pg
pg.close()
# ── Landing page tests ──────────────────────────────────────
def test_landing_screenshot(live_server, page):
"""Take a full-page screenshot of the landing page."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SCREENSHOTS_DIR / "landing_full.png"), full_page=True)
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")
# Pico sets background on <html>, body may be transparent
bg_color = page.evaluate("""
(() => {
const html_bg = getComputedStyle(document.documentElement).backgroundColor;
const body_bg = getComputedStyle(document.body).backgroundColor;
// Use whichever is non-transparent
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 dark (navy/black), not light/invisible."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
# Check the hero h1
h1_color = page.evaluate(
"getComputedStyle(document.querySelector('h1')).color"
)
rgb = parse_rgb(h1_color)
brightness = sum(rgb) / 3
assert brightness < 100, f"H1 too light/invisible: {h1_color} (brightness={brightness})"
# Check section h2s
h2_colors = page.evaluate("""
Array.from(document.querySelectorAll('h2')).map(
el => getComputedStyle(el).color
)
""")
for i, color in enumerate(h2_colors):
rgb = parse_rgb(color)
brightness = sum(rgb) / 3
assert brightness < 100, f"H2[{i}] too light: {color} (brightness={brightness})"
# Check h3s (article card headings)
h3_colors = page.evaluate("""
Array.from(document.querySelectorAll('h3')).map(
el => getComputedStyle(el).color
)
""")
for i, color in enumerate(h3_colors):
rgb = parse_rgb(color)
brightness = sum(rgb) / 3
assert brightness < 100, f"H3[{i}] too light: {color} (brightness={brightness})"
def test_landing_logo_present(live_server, page):
"""Verify the logo image is in the nav and visible."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
logo = page.locator("nav img[alt]")
expect(logo).to_be_visible()
# Check natural dimensions (should be loaded, not broken)
natural_width = logo.evaluate("el => el.naturalWidth")
assert natural_width > 0, "Logo image failed to load (naturalWidth=0)"
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")
# Get bounding boxes of all nav <li> items in the right <ul>
boxes = page.evaluate("""
(() => {
const uls = document.querySelectorAll('nav ul');
if (uls.length < 2) return [];
const items = uls[1].querySelectorAll('li');
return Array.from(items).map(li => {
const r = li.getBoundingClientRect();
return {top: r.top, bottom: r.bottom, left: r.left, right: r.right};
});
})()
""")
# Check no horizontal overlap between consecutive items
for i in range(len(boxes) - 1):
a, b = boxes[i], boxes[i + 1]
h_overlap = a["right"] - b["left"]
# Allow a few px of overlap from padding/margins, but not significant
assert h_overlap < 10, (
f"Nav items {i} and {i+1} overlap horizontally by {h_overlap:.0f}px"
)
def test_landing_teaser_light_theme(live_server, page):
"""Verify teaser calculator has white/light background."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
teaser_bg = page.evaluate(
"getComputedStyle(document.querySelector('.teaser-calc')).backgroundColor"
)
rgb = parse_rgb(teaser_bg)
brightness = sum(rgb) / 3
assert brightness > 240, f"Teaser calc background too dark: {teaser_bg}"
# ── Auth page tests ──────────────────────────────────────────
def test_login_screenshot(live_server, page):
"""Take a screenshot of the login page."""
page.goto(f"{live_server}/auth/login")
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SCREENSHOTS_DIR / "login.png"), full_page=True)
def test_signup_screenshot(live_server, page):
"""Take a screenshot of the signup page."""
page.goto(f"{live_server}/auth/signup")
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SCREENSHOTS_DIR / "signup.png"), full_page=True)
# ── Mobile viewport tests ───────────────────────────────────
def test_mobile_landing_screenshot(live_server, browser):
"""Take a mobile-width screenshot of the landing page."""
page = browser.new_page(viewport={"width": 375, "height": 812})
page.goto(live_server)
page.wait_for_load_state("networkidle")
page.screenshot(path=str(SCREENSHOTS_DIR / "landing_mobile.png"), full_page=True)
page.close()
def test_mobile_nav_no_overflow(live_server, browser):
"""Verify nav doesn't overflow on mobile."""
page = browser.new_page(viewport={"width": 375, "height": 812})
page.goto(live_server)
page.wait_for_load_state("networkidle")
page.evaluate("""
(() => {
const nav = document.querySelector('nav');
return nav.scrollWidth > nav.clientWidth;
})()
""")
page.close()
# Pico's nav may wrap on mobile, which is fine — just verify no JS errors
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 els = document.querySelectorAll('article, section, header, footer, main, div');
for (const el of els) {
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: el.className, bg, brightness});
}
}
}
return dark;
})()
""")
assert len(dark_elements) == 0, (
f"Found {len(dark_elements)} elements with dark backgrounds: "
f"{dark_elements[:3]}"
)