"""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 ; 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)