Files
padelnomics/web/tests/test_visual.py
Deeman 6e1e6b0484 fix(tests): fix all 10 e2e test failures
- test_planner_calculate_htmx: click capex tab to reveal #tab-content
  (it starts display:none and only shows after tab switch)
- test_planner_quote_sidebar_visible_wide: use browser.new_page() instead
  of page.context.new_page() (default contexts don't support new_page)
- test_login/signup/quote_step1_loads: add .first to avoid strict mode
  violation from the feedback popover form
- test_language_switcher_en_to_de: verify footer link href + navigate
  directly instead of click (avoids off-screen element timing issues)
- test_landing_nav_no_overlap: filter display:none elements (zero-width
  bounding rect) so mobile-only nav div doesn't skew overlap check
- test_quote_wizard_*: replace schema.sql (doesn't exist) with migrate()
  approach matching test_visual.py and test_e2e_flows.py; fix URL from
  /leads/quote to /en/leads/quote; use label click for display:none pill
  radios; add missing required fields for steps 6 (financing_status +
  decision_process) and 8 (services_needed); add contact_phone to step 9

All 1018 unit tests + 61 e2e tests now pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 11:42:54 +01:00

335 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 import core
from padelnomics.app import create_app
from padelnomics.migrations.migrate import migrate
from playwright.sync_api import expect, sync_playwright
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]}"
)