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>
This commit is contained in:
Deeman
2026-02-23 18:40:11 +01:00
parent 5b6c4182f7
commit 777333e918
6 changed files with 148 additions and 349 deletions

View File

@@ -10,20 +10,11 @@ Run explicitly with:
Screenshots are saved to tests/screenshots/ for manual review.
"""
import asyncio
import multiprocessing
import sqlite3
import tempfile
import time
import re
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
from playwright.sync_api import expect
pytestmark = pytest.mark.visual
@@ -33,76 +24,13 @@ 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."""
"""Desktop page with dark OS preference to catch theme leaks."""
pg = browser.new_page(
viewport={"width": 1280, "height": 900},
color_scheme="dark",
@@ -114,24 +42,15 @@ def page(browser):
# ── 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;
})()
@@ -146,8 +65,7 @@ def test_landing_heading_colors(live_server, page):
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 is intentionally white (#fff) on the dark hero — verify it has a color set.
h1_color = page.evaluate(
"getComputedStyle(document.querySelector('h1')).color"
)
@@ -163,7 +81,7 @@ def test_landing_heading_colors(live_server, page):
""")
for i, item in enumerate(h2_data):
if item["inDark"]:
continue # white-on-dark is intentional
continue
rgb = parse_rgb(item["color"])
brightness = sum(rgb) / 3
assert brightness < 100, f"H2[{i}] too light: {item['color']} (brightness={brightness})"
@@ -180,8 +98,6 @@ def test_landing_heading_colors(live_server, page):
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})"
@@ -190,7 +106,6 @@ def test_landing_logo_present(live_server, page):
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()
@@ -203,7 +118,6 @@ def test_landing_nav_no_overlap(live_server, page):
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');
@@ -214,14 +128,12 @@ def test_landing_nav_no_overlap(live_server, page):
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
.filter(r => r.width > 0);
})()
""")
# 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"
)
@@ -250,7 +162,6 @@ def test_landing_logo_links_to_landing(live_server, page):
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}"
)
@@ -261,7 +172,6 @@ def test_landing_teaser_light_theme(live_server, page):
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"
)
@@ -270,36 +180,6 @@ def test_landing_teaser_light_theme(live_server, page):
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)
@@ -308,11 +188,9 @@ def test_landing_no_dark_remnants(live_server, page):
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;
@@ -333,3 +211,34 @@ def test_landing_no_dark_remnants(live_server, page):
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()