Spaces:
Running
Running
| """Local E2E of the homeowner widget. The model pipeline is mocked at the network layer | |
| (Playwright route interception) — no server fake needed, no models, no Google $.""" | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| import pytest | |
| from playwright.sync_api import expect | |
| pytestmark = pytest.mark.local | |
| # 1x1 transparent PNG — enough for the overlay <img>; the editable SVG polygon overlays it. | |
| _PNG = base64.b64decode( | |
| "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC") | |
| _QUOTE = { | |
| "result_id": "e2e-r1", "formatted_address": "5534 Mayberry St, Omaha, NE 68106", | |
| "lawn_sqft": 4200, "confidence": "high", "method": "lidar+rgb", "has_overlay": True, | |
| "lawn_polygon_px": [[300, 320], [980, 300], [1010, 980], [280, 1000]], | |
| "pixel_area_sqft": 0.35, "image_size_px": [1280, 1280], | |
| "pricing": {"currency": "USD", "line_items": [ | |
| {"service": "mow", "label": "Mowing", "unit": "per_1000_sqft", "rate": 10, "rate_per_1000_sqft": 10, "min_charge": 30}, | |
| {"service": "fert", "label": "Fertilizer", "unit": "per_1000_sqft", "rate": 8, "rate_per_1000_sqft": 8, "min_charge": 25}], | |
| "bundles": [{"id": "spring", "name": "Spring Package", "cadence": "seasonal", "mode": "fixed", | |
| "price": 120.0, "subtotal": 140.0, "savings": 20.0, "line_items": [ | |
| {"service": "mow", "label": "Mowing", "unit": "per_1000_sqft", "rate": 10, "rate_per_1000_sqft": 10, "min_charge": 30, "price": 42.0}, | |
| {"service": "fert", "label": "Fertilizer", "unit": "per_1000_sqft", "rate": 8, "rate_per_1000_sqft": 8, "min_charge": 25, "price": 33.6}]}]}, | |
| } | |
| def test_widget_quote_outline_and_book(page, app_url): | |
| booked = {"n": 0} | |
| def _json(route, obj): | |
| route.fulfill(status=200, content_type="application/json", body=json.dumps(obj)) | |
| page.route("**/autocomplete*", lambda r: _json(r, [])) | |
| page.route("**/quote", lambda r: _json(r, _QUOTE)) | |
| page.route("**/overlay/*.png", lambda r: r.fulfill(status=200, content_type="image/png", body=_PNG)) | |
| page.route("**/lead", lambda r: _json(r, {})) | |
| def _book(route): | |
| booked["n"] += 1 | |
| _json(route, {"booked": True, "notified": True}) | |
| page.route("**/book", _book) | |
| page.goto(f"{app_url}/widget?key=pub-dev", wait_until="networkidle") | |
| page.wait_for_selector("#address", timeout=15000) | |
| page.fill("#address", "5534 Mayberry St, Omaha, NE 68106") | |
| page.click("#go") | |
| # lead-capture popup covers the (mocked) measurement | |
| page.wait_for_selector("#overlay.show", timeout=8000) | |
| page.fill("#email", "sam@example.com") | |
| page.click("#leadGo") | |
| # price + editable outline revealed | |
| page.wait_for_selector("#pricebox", timeout=10000) | |
| page.wait_for_selector("#lawnpic svg.lawnsvg", timeout=8000) | |
| assert page.locator("#lawnpic svg.lawnsvg .vh").count() >= 4 # draggable outline handles | |
| assert page.locator("#checkout input[data-svc]").count() >= 1 # services (default-selected) | |
| # book: T&C + submit (services are pre-checked) | |
| page.check("#tnc") | |
| page.click("#bookGo") | |
| expect(page.locator("#checkout")).to_contain_text("reach out", timeout=8000) | |
| assert booked["n"] == 1 | |
| def test_widget_editor_add_point_on_a_side(page, app_url): | |
| # Parity with the dashboard editor: every side has a ghost handle; dragging one inserts a vertex. | |
| def _json(route, obj): | |
| route.fulfill(status=200, content_type="application/json", body=json.dumps(obj)) | |
| page.route("**/autocomplete*", lambda r: _json(r, [])) | |
| page.route("**/quote", lambda r: _json(r, _QUOTE)) | |
| page.route("**/overlay/*.png", lambda r: r.fulfill(status=200, content_type="image/png", body=_PNG)) | |
| page.route("**/lead", lambda r: _json(r, {})) | |
| page.goto(f"{app_url}/widget?key=pub-dev", wait_until="networkidle") | |
| page.wait_for_selector("#address", timeout=15000) | |
| page.fill("#address", "5534 Mayberry St, Omaha, NE 68106") | |
| page.click("#go") | |
| page.wait_for_selector("#overlay.show", timeout=8000) | |
| page.fill("#email", "sam@example.com") | |
| page.click("#leadGo") | |
| page.wait_for_selector("#lawnpic svg.lawnsvg .mh", timeout=10000) | |
| assert page.locator("#lawnpic svg.lawnsvg .mh").count() >= 4 # a ghost on every side | |
| v0 = page.locator("#lawnpic svg.lawnsvg .vh").count() | |
| mh = page.locator("#lawnpic svg.lawnsvg .mh").first | |
| box = mh.bounding_box() | |
| page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2) | |
| page.mouse.down() | |
| page.mouse.move(box["x"] + 30, box["y"] + 30, steps=6) | |
| page.mouse.up() | |
| page.wait_for_timeout(200) | |
| assert page.locator("#lawnpic svg.lawnsvg .vh").count() == v0 + 1 # a point was inserted | |
| def test_widget_book_a_package(page, app_url): | |
| captured = {} | |
| def _json(route, obj): | |
| route.fulfill(status=200, content_type="application/json", body=json.dumps(obj)) | |
| page.route("**/autocomplete*", lambda r: _json(r, [])) | |
| page.route("**/quote", lambda r: _json(r, _QUOTE)) | |
| page.route("**/overlay/*.png", lambda r: r.fulfill(status=200, content_type="image/png", body=_PNG)) | |
| page.route("**/lead", lambda r: _json(r, {})) | |
| def _book(route): | |
| captured.update(route.request.post_data_json) | |
| _json(route, {"booked": True, "notified": True}) | |
| page.route("**/book", _book) | |
| page.goto(f"{app_url}/widget?key=pub-dev", wait_until="networkidle") | |
| page.wait_for_selector("#address", timeout=15000) | |
| page.fill("#address", "5534 Mayberry St, Omaha, NE 68106") | |
| page.click("#go") | |
| page.wait_for_selector("#overlay.show", timeout=8000) | |
| page.fill("#email", "sam@example.com") | |
| page.click("#leadGo") | |
| # A package option is offered; choosing it sets the total to the bundle price. | |
| page.wait_for_selector("#checkout .pkg", timeout=10000) | |
| page.check('#checkout .pkg input[value="spring"]') | |
| expect(page.locator("#bookTotal")).to_contain_text("120") | |
| page.check("#tnc") | |
| page.click("#bookGo") | |
| expect(page.locator("#checkout")).to_contain_text("reach out", timeout=8000) | |
| assert captured.get("bundle_id") == "spring" and captured.get("cadence") == "seasonal" | |
| def test_widget_bad_address_shows_message(page, app_url): | |
| # a 422 from /quote → a friendly on-page message, not a crash | |
| page.route("**/quote", lambda r: r.fulfill( | |
| status=422, content_type="application/json", | |
| body=json.dumps({"detail": "We couldn't find that address."}))) | |
| page.goto(f"{app_url}/widget?key=pub-dev", wait_until="networkidle") | |
| page.wait_for_selector("#address", timeout=15000) | |
| page.fill("#address", "not a real place") | |
| page.click("#go") | |
| page.wait_for_selector("#overlay.show", timeout=8000) | |
| page.fill("#email", "x@y.com") | |
| page.click("#leadGo") | |
| expect(page.locator("#state")).to_contain_text("address", timeout=8000) | |