lawn-estimator-dev / e2e /test_widget.py
TempuraML's picture
feat: WS5 — service bundles (config builder → widget packages → Overview) (#138)
7507549 unverified
Raw
History Blame Contribute Delete
6.9 kB
"""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)