Files
padelnomics/web/tests/test_visual.py
2026-02-22 23:22:51 +01:00

336 lines
12 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 sqlite3
import tempfile
import time
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from padelnomics.app import create_app
from padelnomics.migrations.migrate import migrate
from playwright.sync_api import expect, sync_playwright
from padelnomics import core
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():
# Build schema DDL by replaying migrations against a temp DB
tmp_db = str(Path(tempfile.mkdtemp()) / "schema.db")
migrate(tmp_db)
tmp_conn = sqlite3.connect(tmp_db)
rows = tmp_conn.execute(
"SELECT sql FROM sqlite_master"
" WHERE sql IS NOT NULL"
" AND name NOT LIKE 'sqlite_%'"
" AND name NOT LIKE '%_fts_%'"
" AND name != '_migrations'"
" ORDER BY rowid"
).fetchall()
tmp_conn.close()
schema_ddl = ";\n".join(r[0] for r in rows) + ";"
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys=ON")
await conn.executescript(schema_ddl)
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 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_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")
# Tailwind sets background on body via base layer
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 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 background — skip brightness check.
# Instead verify it's not transparent/invisible (i.e. has some 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 # white-on-dark is intentional
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
# Allow up to 150 — catches near-white text while accepting readable
# medium-gray secondary headings (e.g. slate #64748B ≈ brightness 118).
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 is a text <span> inside an <a class="nav-logo">, not an <img>
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")
# Get bounding boxes of visible direct children in the nav inner div
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); // skip display:none / hidden elements
})()
""")
# 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_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")
# Accept "/" or any language-prefixed landing path, e.g. "/en/"
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")
# Was .teaser-calc; now .roi-calc (white card embedded in dark hero)
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}"
# ── 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_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 = [];
// Known intentional dark sections on the landing page
const allowedClasses = ['hero-dark', 'cta-card'];
const els = document.querySelectorAll('article, section, header, footer, main, div');
for (const el of els) {
// Skip intentionally dark sections
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]}"
)