import { test, expect } from "./fixtures/test"; import { mockBRouter, latLngToPixel } from "./fixtures/brouter-mock"; test.describe("Planner", () => { test("loads the home page", async ({ page }) => { await page.goto("/"); await expect(page).toHaveTitle(/trails\.cool Planner/); await expect(page.getByRole("link", { name: "Start Planning" })).toBeVisible(); }); test("can create a session via API", async ({ request }) => { const response = await request.post("/api/sessions", { data: {} }); expect(response.ok()).toBeTruthy(); const body = await response.json(); expect(body.sessionId).toBeTruthy(); expect(body.url).toContain("/session/"); }); test("session page loads with map", async ({ page, request }) => { const response = await request.post("/api/sessions", { data: {} }); const { url } = await response.json(); await page.goto(url); // Title is now a home link await expect(page.getByRole("link", { name: "trails.cool Planner" })).toBeVisible(); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); }); test("session shows connection status", async ({ page, request }) => { const response = await request.post("/api/sessions", { data: {} }); const { url } = await response.json(); await page.goto(url); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); }); test("session has profile selector", async ({ page, request }) => { const response = await request.post("/api/sessions", { data: {} }); const { url } = await response.json(); await page.goto(url); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); const profileSelect = page.getByLabel("Profile:"); await expect(profileSelect).toBeVisible(); await expect(profileSelect).toHaveValue("fastbike"); }); test("session has export GPX button", async ({ page, request }) => { const response = await request.post("/api/sessions", { data: {} }); const { url } = await response.json(); await page.goto(url); await expect(page.getByRole("button", { name: "Export GPX" })).toBeVisible({ timeout: 10000 }); }); test("session shows empty waypoints sidebar", async ({ page, request }) => { const response = await request.post("/api/sessions", { data: {} }); const { url } = await response.json(); await page.goto(url); await expect(page.getByText("Waypoints (0)")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Click on the map to add waypoints")).toBeVisible(); }); test("session has sidebar tabs (waypoints and notes)", async ({ page, request }) => { const response = await request.post("/api/sessions", { data: {} }); const { url } = await response.json(); await page.goto(url); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); // Waypoints tab is active by default await expect(page.getByRole("button", { name: "Waypoints" })).toBeVisible(); await expect(page.getByRole("button", { name: "Notes" })).toBeVisible(); // Switch to Notes tab await page.getByRole("button", { name: "Notes" }).click(); // CodeMirror editor should be visible with the placeholder text await expect(page.getByText("Add notes for this session...")).toBeVisible(); // Switch back to Waypoints tab await page.getByRole("button", { name: "Waypoints" }).click(); await expect(page.getByText("Waypoints (0)")).toBeVisible(); }); test("clicking on route splits it by inserting a waypoint", async ({ page, request }) => { 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 }, ]))}`); 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 }); 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.518, 13.383], 15, { animate: false }); }); await page.waitForTimeout(500); // 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); // Should now have 3 waypoints (original 2 + inserted) await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 }); }); // Note: Ghost marker hover interaction is verified visually via cmux browser. // Playwright's mouse simulation doesn't reliably trigger Leaflet's SVG // pointer events needed for the distance-based snap detection. test("session has color mode toggle", async ({ page, request }) => { const sessionResp = await request.post("/api/sessions", { data: {} }); const { url } = await sessionResp.json(); await mockBRouter(page); // Need a route for the elevation chart (and color selector) to appear await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify([ { lat: 52.520, lon: 13.405 }, { lat: 52.515, lon: 13.351 }, ]))}`); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); // Wait for elevation chart to render with the color mode selector await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); const select = page.locator("select", { has: page.locator("option[value='highway']") }); await expect(select).toBeVisible({ timeout: 5000 }); await expect(select).toHaveValue("plain"); // Switch to elevation await select.selectOption("elevation"); await expect(select).toHaveValue("elevation"); }); test("session has no-go area button", async ({ page, request }) => { const response = await request.post("/api/sessions", { data: {} }); const { url } = await response.json(); await page.goto(url); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByTitle("Draw no-go area")).toBeVisible(); }); test("map zooms to fit the route when opened with initial waypoints", async ({ page, request }) => { 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 }, ]; await page.goto(`${url}?waypoints=${encodeURIComponent(JSON.stringify(waypoints))}`); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); 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(() => { const map = (window as any).__leafletMap; return map ? map.getZoom() : null; }); expect(zoom).not.toBeNull(); expect(zoom).toBeGreaterThan(6); }); test("can create session with initial waypoints", async ({ request }) => { const response = await request.post("/api/sessions", { data: { gpx: 'BerlinMunich', }, }); const body = await response.json(); expect(body.initialWaypoints).toHaveLength(2); expect(body.initialWaypoints[0].name).toBe("Berlin"); }); test("expired session returns 404", async ({ page }) => { const response = await page.goto("/session/nonexistent-id"); expect(response?.status()).toBe(404); }); test("home page has Import GPX button", async ({ page }) => { await page.goto("/"); await expect(page.getByText("Import GPX")).toBeVisible(); }); test("import invalid GPX shows error", async ({ page }) => { await page.goto("/"); const fileChooserPromise = page.waitForEvent("filechooser"); await page.getByText("Import GPX").click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles({ name: "broken.gpx", mimeType: "application/gpx+xml", buffer: Buffer.from("not valid xml at all"), }); // Should show error, stay on home page await expect(page.getByText(/Could not read|konnte nicht/)).toBeVisible({ timeout: 5000 }); await expect(page).toHaveURL(/^\/$|\/$/); }); test("import GPX from home page creates session with waypoints", async ({ page }) => { await page.goto("/"); const gpx = ` 34 113 519 `; const fileChooserPromise = page.waitForEvent("filechooser"); await page.getByText("Import GPX").click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles({ name: "test-route.gpx", mimeType: "application/gpx+xml", buffer: Buffer.from(gpx), }); // Should redirect to a session await expect(page).toHaveURL(/\/session\//, { timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); }); test("toggle overnight on waypoint shows day breakdown in sidebar", async ({ page, request }) => { 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.516, lon: 13.377 }, { lat: 52.510, lon: 13.390 }, ]))}`); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 15000 }); const sidebar = page.locator("aside"); await expect(sidebar.getByText(/\d+\.\d+ km/).first()).toBeVisible({ timeout: 15000 }); // Hover waypoint 2 to reveal controls, click the overnight toggle (moon icon) const waypointRows = sidebar.locator("li").filter({ has: page.locator("span.rounded-full") }); const secondRow = waypointRows.nth(1); await secondRow.hover(); const moonButton = secondRow.getByTitle(/overnight/i); await moonButton.click(); // Day breakdown should appear in the sidebar await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 }); await expect(sidebar.getByText("Day 2")).toBeVisible({ timeout: 5000 }); }); test("export GPX with day breaks includes overnight metadata", async ({ page, request }) => { const sessionResp = await request.post("/api/sessions", { data: {} }); const { url } = await sessionResp.json(); await mockBRouter(page); // Import a GPX with overnight waypoints const gpx = ` Berlin Mitteovernight Kreuzberg 34 40 35 `; await page.goto(url); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); // Drop the GPX file onto the map const dataTransfer = await page.evaluateHandle((content) => { const dt = new DataTransfer(); const file = new File([content], "multi-day.gpx", { type: "application/gpx+xml" }); dt.items.add(file); return dt; }, gpx); const map = page.locator(".leaflet-container"); await map.dispatchEvent("dragenter", { dataTransfer }); await page.getByText("Drop GPX file here").waitFor({ timeout: 3000 }); page.on("dialog", (dialog) => dialog.accept()); await map.dispatchEvent("drop", { dataTransfer }); // Wait for waypoints to load await expect(page.getByText("Waypoints (3)")).toBeVisible({ timeout: 10000 }); // The overnight waypoint should show day breakdown in the sidebar const sidebar = page.locator("aside"); await expect(sidebar.getByText("Day 1")).toBeVisible({ timeout: 5000 }); }); test("enable hillshading overlay loads tiles", async ({ page, request }) => { const sessionResp = await request.post("/api/sessions", { data: {} }); const { url } = await sessionResp.json(); // Track hillshading tile requests const hillshadingRequests: string[] = []; await page.route("**/tiles.wmflabs.org/hillshading/**", async (route) => { hillshadingRequests.push(route.request().url()); await route.fulfill({ status: 200, contentType: "image/png", body: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQABNjN9GQAAAABJRUEFTuQmCC", "base64"), }); }); await page.goto(url); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); // Enable hillshading via DOM — Leaflet's layers control hover is hard to automate await page.evaluate(() => { const inputs = document.querySelectorAll(".leaflet-control-layers-overlays input"); if (inputs[0]) inputs[0].click(); }); await page.waitForTimeout(2000); expect(hillshadingRequests.length).toBeGreaterThan(0); }); test("road type color mode renders chart and shows legend", async ({ page, request }) => { 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 }, ]))}`); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); // Wait for the elevation chart to render, then find the color mode select (has a "highway" option) await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); const select = page.locator("select", { has: page.locator("option[value='highway']") }); await expect(select).toBeVisible({ timeout: 5000 }); // Switch to highway (road type) mode await select.selectOption("highway"); await expect(select).toHaveValue("highway"); // Chart title should show "Road Type Profile" await expect(page.getByText("Road Type Profile")).toBeVisible({ timeout: 5000 }); // Legend should show highway types from mock data await expect(page.getByText("secondary")).toBeVisible({ timeout: 5000 }); await expect(page.getByText("residential")).toBeVisible({ timeout: 5000 }); }); test("road type hover label shows highway type", async ({ page, request }) => { 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 }, ]))}`); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); // Switch to highway mode await expect(page.locator("canvas")).toBeVisible({ timeout: 10000 }); const select = page.locator("select", { has: page.locator("option[value='highway']") }); await expect(select).toBeVisible({ timeout: 5000 }); await select.selectOption("highway"); // Hover the canvas to trigger the hover label const canvas = page.locator("canvas"); await expect(canvas).toBeVisible({ timeout: 5000 }); const box = await canvas.boundingBox(); if (box) { // Hover near the middle of the chart await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); } // The hover label is drawn on canvas — we can't assert text directly, // but we verify no errors occurred and the chart re-renders on hover. // The canvas should still be visible (no crash). await expect(canvas).toBeVisible(); }); test("enable POI category shows markers on map", async ({ page, request }) => { const sessionResp = await request.post("/api/sessions", { data: {} }); const { url } = await sessionResp.json(); // The browser queries POIs through the planner's `/api/overpass` // proxy (not the upstream Overpass directly), so that's what we // intercept here. Response body is the same Overpass JSON shape. await page.route("**/api/overpass", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ elements: [ { type: "node", id: 12345, lat: 52.52, lon: 13.405, tags: { amenity: "drinking_water", name: "Test Brunnen" }, }, ], }), }); }); await page.goto(url); await expect(page.locator(".leaflet-container")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("Connected")).toBeVisible({ timeout: 15000 }); // Zoom in to Berlin center to meet zoom threshold and see the mock POI await page.evaluate(() => { const map = (window as any).__leafletMap; if (map) map.setView([52.52, 13.405], 14, { animate: false }); }); await page.waitForTimeout(500); // Open POI panel and enable Drinking water await page.getByTitle("Points of Interest").click(); await page.getByText("Drinking water").click(); // Wait for mock Overpass response and marker rendering await page.waitForTimeout(3000); // Verify POI marker rendered — check for marker with the water emoji in the pane const markerCount = await page.locator(".leaflet-marker-pane .leaflet-marker-icon").count(); // Should have at least one POI marker (beyond any waypoint markers) expect(markerCount).toBeGreaterThan(0); // Verify the Overpass mock was actually called by checking the POI panel count const panelText = await page.getByText("Drinking water").textContent(); expect(panelText).toBeTruthy(); }); });