Files
padelnomics/web/tests/test_quote_wizard.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

275 lines
8.9 KiB
Python

"""
Playwright tests for the 9-step quote wizard flow.
Tests the full happy path, back navigation with data preservation,
and validation error handling.
Run explicitly with:
uv run pytest -m visual tests/test_quote_wizard.py -v
"""
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 _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
ready_event.set()
await app.run_task(host="127.0.0.1", port=5112)
asyncio.run(_serve())
@pytest.fixture(scope="module")
def live_server():
"""Start a live Quart server on port 5112 for quote wizard tests."""
ready = multiprocessing.Event()
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
proc.start()
ready.wait(timeout=10)
time.sleep(1)
yield "http://127.0.0.1:5112"
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 for the quote wizard tests."""
pg = browser.new_page(viewport={"width": 1280, "height": 900})
yield pg
pg.close()
def _check_radio(page, name, value):
"""Click the label for a CSS-hidden radio/checkbox pill button."""
page.locator(f"label:has(input[name='{name}'][value='{value}'])").click()
def _fill_step_1(page):
"""Fill step 1: facility type = indoor."""
_check_radio(page, "facility_type", "indoor")
def _fill_step_2(page):
"""Fill step 2: country = Germany."""
page.select_option("select[name='country']", "DE")
def _fill_step_5(page):
"""Fill step 5: timeline = 3-6mo."""
_check_radio(page, "timeline", "3-6mo")
def _fill_step_6(page):
"""Fill step 6: financing status + decision process (both required)."""
_check_radio(page, "financing_status", "self_funded")
_check_radio(page, "decision_process", "solo")
def _fill_step_7(page):
"""Fill step 7: stakeholder type = entrepreneur."""
_check_radio(page, "stakeholder_type", "entrepreneur")
def _fill_step_8(page):
"""Fill step 8: select at least one service (required, checkbox pill)."""
_check_radio(page, "services_needed", "installation")
def _fill_step_9(page):
"""Fill step 9: contact details (name, email, phone, consent)."""
page.fill("input[name='contact_name']", "Test User")
page.fill("input[name='contact_email']", "test@example.com")
page.fill("input[name='contact_phone']", "+49 123 456789")
page.locator("input[name='consent']").check()
def _click_next(page):
"""Click the Next button and wait for HTMX swap."""
page.locator("button.q-btn-next").click()
page.wait_for_timeout(500)
def _click_back(page):
"""Click the Back button and wait for HTMX swap."""
page.locator("button.q-btn-back").click()
page.wait_for_timeout(500)
def test_quote_wizard_full_flow(live_server, page):
"""Complete all 9 steps and submit — verify success page shown."""
page.goto(f"{live_server}/en/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: Your Project
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
_fill_step_1(page)
_click_next(page)
# Step 2: Location
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
_fill_step_2(page)
_click_next(page)
# Step 3: Build Context (optional — just click next)
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
_click_next(page)
# Step 4: Project Phase (optional)
expect(page.locator("h2.q-step-title")).to_contain_text("Project Phase")
_click_next(page)
# Step 5: Timeline
expect(page.locator("h2.q-step-title")).to_contain_text("Timeline")
_fill_step_5(page)
_click_next(page)
# Step 6: Financing (has required fields)
expect(page.locator("h2.q-step-title")).to_contain_text("Financing")
_fill_step_6(page)
_click_next(page)
# Step 7: About You
expect(page.locator("h2.q-step-title")).to_contain_text("About You")
_fill_step_7(page)
_click_next(page)
# Step 8: Services Needed (at least one required)
expect(page.locator("h2.q-step-title")).to_contain_text("Services Needed")
_fill_step_8(page)
_click_next(page)
# Step 9: Contact Details
expect(page.locator("h2.q-step-title")).to_contain_text("Contact Details")
_fill_step_9(page)
# Submit the form
page.locator("button.q-btn-submit").click()
page.wait_for_load_state("networkidle")
# Should see either success page or verification sent page (both acceptable)
body_text = page.locator("body").inner_text()
assert "matched" in body_text.lower() or "check your email" in body_text.lower() or "verify" in body_text.lower(), \
f"Expected success or verification page, got: {body_text[:200]}"
page.screenshot(path=str(SCREENSHOTS_DIR / "quote_wizard_submitted.png"), full_page=True)
def test_quote_wizard_back_navigation(live_server, page):
"""Go forward 3 steps, go back, verify data preserved in form fields."""
page.goto(f"{live_server}/en/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: select Indoor
_fill_step_1(page)
_click_next(page)
# Step 2: select Germany
_fill_step_2(page)
page.fill("input[name='city']", "Berlin")
_click_next(page)
# Step 3: select a build context (radio is display:none, click label instead)
_check_radio(page, "build_context", "new_standalone")
_click_next(page)
# Step 4: now go back to step 3
expect(page.locator("h2.q-step-title")).to_contain_text("Project Phase")
_click_back(page)
# Verify we're on step 3 and build_context is preserved
expect(page.locator("h2.q-step-title")).to_contain_text("Build Context")
checked = page.locator("input[name='build_context'][value='new_standalone']").is_checked()
assert checked, "Build context 'new_standalone' should still be checked after going back"
# Go back to step 2 and verify data preserved
_click_back(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
country_val = page.locator("select[name='country']").input_value()
assert country_val == "DE", f"Country should be DE, got {country_val}"
city_val = page.locator("input[name='city']").input_value()
assert city_val == "Berlin", f"City should be Berlin, got {city_val}"
def test_quote_wizard_validation_errors(live_server, page):
"""Skip a required field on step 1 — verify error shown on same step."""
page.goto(f"{live_server}/en/leads/quote")
page.wait_for_load_state("networkidle")
# Step 1: DON'T select facility_type, just click Next
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
_click_next(page)
# Should still be on step 1 with an error hint
expect(page.locator("h2.q-step-title")).to_contain_text("Your Project")
error_hint = page.locator(".q-error-hint")
expect(error_hint).to_be_visible()
# Now fill the field and proceed — should work
_fill_step_1(page)
_click_next(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
# Skip country (required on step 2) — should stay on step 2
_click_next(page)
expect(page.locator("h2.q-step-title")).to_contain_text("Location")
error_hint = page.locator(".q-error-hint")
expect(error_hint).to_be_visible()