From e6c68fb71b1a4760441a67dd316d31171d4f6f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 11 Apr 2026 01:07:39 +0200 Subject: [PATCH] Add BRouter mock for deterministic E2E tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - brouter-mock.ts: Canned route responses for 2 and 3 waypoint sessions, plus latLngToPixel helper for pixel-precise map interactions - planner.test.ts: Route split, map zoom, and overnight tests now use mockBRouter() for instant deterministic responses instead of real BRouter — fixes all 3 flaky tests - Used .first() for sidebar km locator to avoid strict mode violations when both header summary and stats footer match All 38 E2E tests pass in 9.7s (was 34s+ with real BRouter). Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/fixtures/brouter-mock.ts | 97 ++++++++++++++++++++++++++++++++++++ e2e/planner.test.ts | 44 ++++++++-------- 2 files changed, 118 insertions(+), 23 deletions(-) create mode 100644 e2e/fixtures/brouter-mock.ts diff --git a/e2e/fixtures/brouter-mock.ts b/e2e/fixtures/brouter-mock.ts new file mode 100644 index 0000000..27bb9c8 --- /dev/null +++ b/e2e/fixtures/brouter-mock.ts @@ -0,0 +1,97 @@ +import type { Page } from "@playwright/test"; + +/** + * Canned enriched route for 3 nearby Berlin waypoints: + * [52.520, 13.405] → [52.516, 13.377] → [52.510, 13.390] + * + * ~3.2km total, 6 track points with elevation. + */ +const MOCK_ROUTE_3WP = { + geojson: { + type: "FeatureCollection", + features: [{ + type: "Feature", + geometry: { + type: "LineString", + coordinates: [ + [13.405, 52.520, 34], [13.398, 52.519, 36], [13.391, 52.518, 38], + [13.384, 52.517, 40], [13.377, 52.516, 42], + [13.379, 52.514, 40], [13.382, 52.513, 38], + [13.385, 52.512, 36], [13.388, 52.511, 35], [13.390, 52.510, 34], + ], + }, + properties: {}, + }], + }, + coordinates: [ + [13.405, 52.520, 34], [13.398, 52.519, 36], [13.391, 52.518, 38], + [13.384, 52.517, 40], [13.377, 52.516, 42], + [13.379, 52.514, 40], [13.382, 52.513, 38], + [13.385, 52.512, 36], [13.388, 52.511, 35], [13.390, 52.510, 34], + ], + segmentBoundaries: [0, 4], + totalLength: 3200, + totalAscend: 8, + surfaces: [], +}; + +/** + * Canned enriched route for 2 nearby Berlin waypoints: + * [52.520, 13.405] → [52.515, 13.351] + * + * ~3.8km total, 6 track points with elevation. + */ +const MOCK_ROUTE_2WP = { + geojson: { + type: "FeatureCollection", + features: [{ + type: "Feature", + geometry: { + type: "LineString", + coordinates: [ + [13.405, 52.520, 34], [13.394, 52.519, 36], [13.383, 52.518, 40], + [13.372, 52.517, 38], [13.361, 52.516, 36], [13.351, 52.515, 35], + ], + }, + properties: {}, + }], + }, + coordinates: [ + [13.405, 52.520, 34], [13.394, 52.519, 36], [13.383, 52.518, 40], + [13.372, 52.517, 38], [13.361, 52.516, 36], [13.351, 52.515, 35], + ], + segmentBoundaries: [0], + totalLength: 3800, + totalAscend: 6, + surfaces: [], +}; + +/** + * Mock the BRouter route API to return instant, deterministic responses. + * Selects the appropriate canned response based on waypoint count. + */ +export async function mockBRouter(page: Page) { + await page.route("**/api/route", async (route) => { + const body = route.request().postDataJSON(); + const wpCount = body?.waypoints?.length ?? 0; + const mock = wpCount >= 3 ? MOCK_ROUTE_3WP : MOCK_ROUTE_2WP; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mock), + }); + }); +} + +/** + * Convert a lat/lon to pixel coordinates on the Leaflet map. + * Requires MapExposer to have set window.__leafletMap. + */ +export async function latLngToPixel(page: Page, lat: number, lon: number): Promise<{ x: number; y: number }> { + return page.evaluate(([la, ln]) => { + const map = (window as any).__leafletMap; + if (!map) throw new Error("__leafletMap not available"); + const point = map.latLngToContainerPoint([la, ln]); + return { x: point.x, y: point.y }; + }, [lat, lon]); +} diff --git a/e2e/planner.test.ts b/e2e/planner.test.ts index eabcc7b..fa10ca3 100644 --- a/e2e/planner.test.ts +++ b/e2e/planner.test.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test"; +import { mockBRouter, latLngToPixel } from "./fixtures/brouter-mock"; test.describe("Planner", () => { test("loads the home page", async ({ page }) => { @@ -86,6 +87,8 @@ test.describe("Planner", () => { const sessionResp = await request.post("/api/sessions", { data: {} }); const { url } = await sessionResp.json(); + await mockBRouter(page); + await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ { lat: 52.520, lon: 13.405 }, { lat: 52.515, lon: 13.351 }, @@ -94,31 +97,23 @@ test.describe("Planner", () => { await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); await expect(page.getByText("Waypoints (2)")).toBeVisible({ timeout: 5000 }); - await expect(page.getByText(/\d+\.\d+ km/)).toBeVisible({ timeout: 20000 }); - // Zoom in and click on the route midpoint to split it - // RouteInteraction uses map-level mousemove, but click on the ghost marker - // inserts the waypoint. We'll use Leaflet events to simulate. + const sidebar = page.locator("aside"); + await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 }); + + // Zoom in to the route midpoint using known coordinates await page.evaluate(() => { const map = (window as any).__leafletMap; if (!map) return; - map.setView([52.5175, 13.378], 14, { animate: false }); + map.setView([52.518, 13.383], 15, { animate: false }); }); - await page.waitForTimeout(1000); + await page.waitForTimeout(500); - // Click on the route via the visible polyline's bounding box - const routePath = page.locator(".leaflet-overlay-pane path").first(); - await expect(routePath).toBeAttached({ timeout: 5000 }); - const box = await routePath.boundingBox(); - if (!box) throw new Error("Route polyline not visible"); + // Click on a known route coordinate to insert a waypoint + const pixel = await latLngToPixel(page, 52.518, 13.383); + await page.mouse.click(pixel.x, pixel.y); - // Click the center of the route path bounding box - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); - - // The click on the map near the route should trigger RouteInteraction - // which shows the ghost and inserts a waypoint. If the click was within - // snap tolerance, a waypoint is inserted. Otherwise, a new waypoint is - // appended (map click). Either way we get 3 waypoints. + // Should now have 3 waypoints (original 2 + inserted) await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 }); }); @@ -155,6 +150,8 @@ test.describe("Planner", () => { const sessionResp = await request.post("/api/sessions", { data: {} }); const { url } = await sessionResp.json(); + await mockBRouter(page); + const waypoints = [ { lat: 52.520, lon: 13.405 }, { lat: 52.515, lon: 13.351 }, @@ -163,8 +160,9 @@ test.describe("Planner", () => { await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); - // Wait for route to compute and fit - await expect(page.getByText(/\d+\.\d+ km/)).toBeVisible({ timeout: 20000 }); + + const sidebar = page.locator("aside"); + await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 }); // The map should have zoomed in to the route bounds (zoom > default 6) const zoom = await page.evaluate(() => { @@ -240,7 +238,8 @@ test.describe("Planner", () => { const sessionResp = await request.post("/api/sessions", { data: {} }); const { url } = await sessionResp.json(); - // Create session with 3 nearby waypoints (short route for fast BRouter response) + await mockBRouter(page); + await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ { lat: 52.520, lon: 13.405 }, { lat: 52.516, lon: 13.377 }, @@ -251,9 +250,8 @@ test.describe("Planner", () => { await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 5000 }); - // Wait for route to compute — the header summary shows distance const sidebar = page.locator("aside"); - await expect(sidebar.getByText(/\d+\.\d+ km/)).toBeVisible({ timeout: 20000 }); + await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 10000 }); // Hover waypoint 2 to reveal controls, click the overnight toggle (moon icon) const waypointRows = sidebar.locator("li").filter({ has: page.locator("span.rounded-full") });