fix logo sizing, heading colors, and add Playwright visual tests
- Trim logo background to transparent, use inline style height to
override Pico's img { height: auto } — nav 24px, footer 20px
- Fix heading colors by setting --pico-h1-color through --pico-h6-color
variables and --pico-color override on heading elements
- Add white-space:nowrap on nav CTA button to prevent wrapping
- Add Playwright visual test suite (11 tests): screenshots of landing,
login, signup, mobile; assertions for light background, dark headings,
logo presence, nav layout, no dark remnants
- Screenshots saved to tests/screenshots/ for manual review
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BIN
padelnomics/tests/screenshots/landing_full.png
Normal file
BIN
padelnomics/tests/screenshots/landing_full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 411 KiB |
BIN
padelnomics/tests/screenshots/landing_mobile.png
Normal file
BIN
padelnomics/tests/screenshots/landing_mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
BIN
padelnomics/tests/screenshots/login.png
Normal file
BIN
padelnomics/tests/screenshots/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
padelnomics/tests/screenshots/signup.png
Normal file
BIN
padelnomics/tests/screenshots/signup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
289
padelnomics/tests/test_visual.py
Normal file
289
padelnomics/tests/test_visual.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Visual regression tests using Playwright.
|
||||
|
||||
Takes screenshots of key pages and verifies styling invariants
|
||||
(heading colors, backgrounds, nav layout, logo presence).
|
||||
|
||||
Usage:
|
||||
uv run pytest tests/test_visual.py -x -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
|
||||
|
||||
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]}"
|
||||
)
|
||||
Reference in New Issue
Block a user