Files
MCMapper-Backend/e2e/tests/region-export.spec.ts
T
octoturge 7b85f4dff1 Add standing Playwright e2e suite; fix two real bugs it caught
The whole point of driving a real browser instead of curling api/frontend
separately: none of this session's prior "live verification" ever exercised
the same-origin routing production relies on (Caddy: /ws*+/api/* -> api,
else -> frontend), so a real browser's relative fetch()/WebSocket calls were
never actually proven to resolve. e2e/proxy.ts mirrors that routing (no
caddy binary available locally); global-setup.ts/global-teardown.ts
orchestrate throwaway infra + seeded data + the api/frontend/proxy
processes end to end.

Getting the suite green surfaced two genuine bugs invisible to unit tests:
- index.pug loaded map.js via two <script type="module"> tags (one moved to
  <head> to fix load-order, the original left in place by mistake), causing
  Alpine's x-init="init()" to run twice and Leaflet to throw "Map container
  is already initialized" on the second call.
- map.js's exportRegion() passed the Alpine-reactive `regionBounds` object
  straight into worker.postMessage(); Alpine wraps assigned state in
  Proxies, which the structured clone algorithm can't clone, so every
  export silently failed. Fixed by spreading into a plain object first.

Covers the two flows flagged all session as verified only at the unit/curl
level: the marker click-to-place/edit popup (including that a marker
created while linked shows up in a second browser context with the same
session, proving server-side sync) and the region-select drag + glTF
export (including a real triggered file download).
2026-08-09 12:34:12 +02:00

88 lines
4.2 KiB
TypeScript

// Covers the region-select drag tool (mousedown/mousemove/mouseup handlers wired in map.js's
// startRegionSelect/updateRegionPreview/finishRegionSelect) and the client-side glTF export
// pipeline (export-worker.js) — the second UI surface flagged throughout MCMapper's Phase 5 work
// as verified only via a headless pipeline run against seeded data, never an actual browser
// drag gesture. See README's e2e section.
//
// The seeded dataset (see global-setup.ts) fills a 5x5-chunk solid terrain footprint centered on
// the world origin — where the map's default view is centered — so a drag anywhere near the
// visible center reliably selects real, meshable terrain without computing exact Leaflet
// pixel<->world math.
import { test, expect, type Page } from "@playwright/test";
async function waitForMapReady(page: Page) {
await page.goto("/");
await expect(page.getByTestId("region-toggle")).toBeVisible();
await page.locator("#map.leaflet-container").waitFor();
}
async function dragOnMap(page: Page, fromFrac: number, toFrac: number) {
const box = await page.locator("#map").boundingBox();
if (!box) throw new Error("#map has no bounding box");
const start = { x: box.x + box.width * fromFrac, y: box.y + box.height * fromFrac };
const end = { x: box.x + box.width * toFrac, y: box.y + box.height * toFrac };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move((start.x + end.x) / 2, (start.y + end.y) / 2, { steps: 5 });
await page.mouse.move(end.x, end.y, { steps: 5 });
await page.mouse.up();
}
test("dragging on the map selects a region and reports the chunk count", async ({ page }) => {
await waitForMapReady(page);
await page.getByTestId("region-toggle").click();
await dragOnMap(page, 0.4, 0.6);
await expect(page.getByTestId("region-status")).toHaveText(/^\d+ chunks? selected$/);
await expect(page.getByTestId("region-export")).toBeVisible();
});
test("exporting a selected region downloads a non-empty .glb file", async ({ page }) => {
await waitForMapReady(page);
await page.getByTestId("region-toggle").click();
await dragOnMap(page, 0.35, 0.65);
await expect(page.getByTestId("region-status")).toHaveText(/^\d+ chunks? selected$/);
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByTestId("region-export").click(),
]);
expect(download.suggestedFilename()).toMatch(/\.glb$/);
const path = await download.path();
expect(path).not.toBeNull();
const { statSync } = await import("node:fs");
const size = statSync(path!).size;
// A well-formed GLB has at minimum a 12-byte header + JSON chunk header — real seeded terrain
// should produce something far larger (actual mesh geometry), so this also catches an
// accidentally-empty export, not just a missing file.
expect(size).toBeGreaterThan(200);
});
test("an oversized selection is rejected with a clear message instead of exporting", async ({ page }) => {
await waitForMapReady(page);
// Zoom out to the map's minimum (minZoom: -4, see map.js's init()) so many more chunks fit in
// the same viewport, making it easy to drag past the 64-chunk cap without needing to compute
// exact pixel<->world math. Leaflet's zoom-out control gains `leaflet-disabled` once at
// minZoom — waiting for that (rather than a fixed click count) avoids racing the zoom
// animation, which silently drops clicks fired faster than it can keep up.
const zoomOut = page.locator(".leaflet-control-zoom-out");
for (let i = 0; i < 8; i++) {
if (await zoomOut.evaluate((el) => el.classList.contains("leaflet-disabled"))) break;
await zoomOut.click();
await page.waitForTimeout(300);
}
await page.getByTestId("region-toggle").click();
// Not 0.02 — that lands the mousedown on Leaflet's own top-left zoom control, which stops
// event propagation before it reaches the map's mousedown listener (startRegionSelect never
// fires). Starting past that corner still covers a huge area at minZoom.
await dragOnMap(page, 0.15, 0.95);
await expect(page.getByTestId("region-status")).toHaveText(/too large/);
// x-show hides via CSS rather than removing the node, so assert visibility, not DOM count.
await expect(page.getByTestId("region-export")).not.toBeVisible();
});