git mv all tracked files from the nested padelnomics/ workspace directory to the git repo root. Merged .gitignore files. No code changes — pure path rename. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
245 lines
7.7 KiB
Python
245 lines
7.7 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 time
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from padelnomics import core
|
|
from padelnomics.app import create_app
|
|
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():
|
|
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
|
|
|
|
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 _fill_step_1(page):
|
|
"""Fill step 1: facility type = indoor."""
|
|
page.locator("input[name='facility_type'][value='indoor']").check()
|
|
|
|
|
|
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."""
|
|
page.locator("input[name='timeline'][value='3-6mo']").check()
|
|
|
|
|
|
def _fill_step_7(page):
|
|
"""Fill step 7: stakeholder type = entrepreneur."""
|
|
page.locator("input[name='stakeholder_type'][value='entrepreneur']").check()
|
|
|
|
|
|
def _fill_step_9(page):
|
|
"""Fill step 9: contact details."""
|
|
page.fill("input[name='contact_name']", "Test User")
|
|
page.fill("input[name='contact_email']", "test@example.com")
|
|
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}/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 (optional)
|
|
expect(page.locator("h2.q-step-title")).to_contain_text("Financing")
|
|
_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 (optional)
|
|
expect(page.locator("h2.q-step-title")).to_contain_text("Services Needed")
|
|
_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}/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
|
|
page.locator("input[name='build_context'][value='new_standalone']").check()
|
|
_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}/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()
|