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