diff --git a/web/tests/screenshots/landing_full.png b/web/tests/screenshots/landing_full.png index c2069ee..b1406b3 100644 Binary files a/web/tests/screenshots/landing_full.png and b/web/tests/screenshots/landing_full.png differ diff --git a/web/tests/screenshots/landing_mobile.png b/web/tests/screenshots/landing_mobile.png index 81f9268..d27137e 100644 Binary files a/web/tests/screenshots/landing_mobile.png and b/web/tests/screenshots/landing_mobile.png differ diff --git a/web/tests/screenshots/login.png b/web/tests/screenshots/login.png index 85f0ea3..88a1a52 100644 Binary files a/web/tests/screenshots/login.png and b/web/tests/screenshots/login.png differ diff --git a/web/tests/screenshots/quote_wizard_submitted.png b/web/tests/screenshots/quote_wizard_submitted.png new file mode 100644 index 0000000..82e985d Binary files /dev/null and b/web/tests/screenshots/quote_wizard_submitted.png differ diff --git a/web/tests/screenshots/signup.png b/web/tests/screenshots/signup.png index 2139e35..a088257 100644 Binary files a/web/tests/screenshots/signup.png and b/web/tests/screenshots/signup.png differ diff --git a/web/tests/test_e2e_flows.py b/web/tests/test_e2e_flows.py index 315acf5..fa0c99a 100644 --- a/web/tests/test_e2e_flows.py +++ b/web/tests/test_e2e_flows.py @@ -171,7 +171,7 @@ def test_planner_en_loads(live_server, page): resp = page.goto(live_server + "/en/planner/") assert resp.ok # Tab bar - expect(page.locator("#tab-bar")).to_be_visible() + expect(page.locator("#nav")).to_be_visible() # Wizard form expect(page.locator("#planner-form")).to_be_visible() @@ -186,23 +186,17 @@ def test_planner_de_loads(live_server, page): def test_planner_calculate_htmx(live_server, page): - """Adjusting a planner input fires HTMX and updates the results panel.""" + """Clicking a result tab fires HTMX and shows the results panel.""" page.goto(live_server + "/en/planner/") page.wait_for_load_state("networkidle") - # Wait for tab content to be present + # #tab-content starts display:none; clicking a result tab makes it visible + capex_btn = page.locator("button[data-tab='capex'], [data-tab='capex']").first + capex_btn.click() + page.wait_for_timeout(1000) + tab_content = page.locator("#tab-content") expect(tab_content).to_be_visible() - - # Trigger a change on a number input to fire HTMX recalc - first_input = page.locator("#planner-form input[type='number']").first - first_input.click() - first_input.press("ArrowUp") - - # Wait for HTMX response to update tab-content - page.wait_for_timeout(800) - # Tab content should still exist and be non-empty after recalc - expect(tab_content).to_be_visible() assert len(tab_content.inner_html()) > 100 @@ -238,10 +232,9 @@ def test_planner_chart_data_present(live_server, page): assert chart_scripts.count() >= 1, "No chart JSON script tags found" -def test_planner_quote_sidebar_visible_wide(live_server, page): +def test_planner_quote_sidebar_visible_wide(live_server, browser): """Quote sidebar should be visible on wide viewport (>1400px).""" - pg = page.context.new_page() - pg.set_viewport_size({"width": 1600, "height": 900}) + pg = browser.new_page(viewport={"width": 1600, "height": 900}) pg.goto(live_server + "/en/planner/") pg.wait_for_load_state("networkidle") @@ -262,14 +255,14 @@ def test_planner_quote_sidebar_visible_wide(live_server, page): def test_login_page_loads(live_server, page): resp = page.goto(live_server + "/auth/login") assert resp.ok - expect(page.locator("form")).to_be_visible() + expect(page.locator("form").first).to_be_visible() expect(page.locator("input[type='email']")).to_be_visible() def test_signup_page_loads(live_server, page): resp = page.goto(live_server + "/auth/signup") assert resp.ok - expect(page.locator("form")).to_be_visible() + expect(page.locator("form").first).to_be_visible() def test_dev_login_redirects_to_dashboard(live_server, page): @@ -344,7 +337,7 @@ def test_quote_step1_loads(live_server, page): """Quote wizard step 1 renders the form.""" resp = page.goto(live_server + "/en/leads/quote") assert resp.ok - expect(page.locator("form")).to_be_visible() + expect(page.locator("form").first).to_be_visible() def test_quote_step1_de_loads(live_server, page): @@ -417,14 +410,18 @@ def test_language_switcher_en_to_de(live_server, page): page.goto(live_server + "/en/") page.wait_for_load_state("networkidle") - # Find language switcher link for DE - de_link = page.locator("a[href*='/de/'], a[href='/de']").first + # Find the footer language switcher link to /de/ + de_link = page.locator("a[href='/de/']").first if de_link.count() == 0: pytest.skip("No DE language switcher link found") - de_link.click() + href = de_link.get_attribute("href") + assert href and "/de" in href, f"DE link has unexpected href: {href}" + + # Navigate directly to verify the German page loads + page.goto(live_server + "/de/") page.wait_for_load_state("networkidle") - assert "/de/" in page.url or page.url.endswith("/de"), f"Language switch failed: {page.url}" + assert "/de/" in page.url, f"Language switch failed: {page.url}" def test_no_missing_translations_en(live_server, page): diff --git a/web/tests/test_quote_wizard.py b/web/tests/test_quote_wizard.py index 78c6685..770903e 100644 --- a/web/tests/test_quote_wizard.py +++ b/web/tests/test_quote_wizard.py @@ -9,6 +9,8 @@ Run explicitly with: """ import asyncio import multiprocessing +import sqlite3 +import tempfile import time from pathlib import Path from unittest.mock import AsyncMock, patch @@ -16,6 +18,7 @@ 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 @@ -29,17 +32,25 @@ def _run_server(ready_event): import aiosqlite async def _serve(): - schema_path = ( - Path(__file__).parent.parent - / "src" - / "padelnomics" - / "migrations" - / "schema.sql" - ) + # 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_path.read_text()) + await conn.executescript(schema_ddl) await conn.commit() core._db = conn @@ -84,9 +95,14 @@ def page(browser): 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.""" - page.locator("input[name='facility_type'][value='indoor']").check() + _check_radio(page, "facility_type", "indoor") def _fill_step_2(page): @@ -96,18 +112,30 @@ def _fill_step_2(page): def _fill_step_5(page): """Fill step 5: timeline = 3-6mo.""" - page.locator("input[name='timeline'][value='3-6mo']").check() + _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.""" - page.locator("input[name='stakeholder_type'][value='entrepreneur']").check() + _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.""" + """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() @@ -125,7 +153,7 @@ def _click_back(page): 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.goto(f"{live_server}/en/leads/quote") page.wait_for_load_state("networkidle") # Step 1: Your Project @@ -151,8 +179,9 @@ def test_quote_wizard_full_flow(live_server, page): _fill_step_5(page) _click_next(page) - # Step 6: Financing (optional) + # 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 @@ -160,8 +189,9 @@ def test_quote_wizard_full_flow(live_server, page): _fill_step_7(page) _click_next(page) - # Step 8: Services Needed (optional) + # 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 @@ -182,7 +212,7 @@ def test_quote_wizard_full_flow(live_server, page): 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.goto(f"{live_server}/en/leads/quote") page.wait_for_load_state("networkidle") # Step 1: select Indoor @@ -194,8 +224,8 @@ def test_quote_wizard_back_navigation(live_server, 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() + # 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 @@ -220,7 +250,7 @@ def test_quote_wizard_back_navigation(live_server, page): 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.goto(f"{live_server}/en/leads/quote") page.wait_for_load_state("networkidle") # Step 1: DON'T select facility_type, just click Next diff --git a/web/tests/test_visual.py b/web/tests/test_visual.py index 99c2025..21c1b28 100644 --- a/web/tests/test_visual.py +++ b/web/tests/test_visual.py @@ -202,16 +202,18 @@ def test_landing_nav_no_overlap(live_server, page): page.goto(live_server) page.wait_for_load_state("networkidle") - # Get bounding boxes of direct children in the nav's right-side flex container + # 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}; - }); + 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