- New mission-map-markers collection (permission-gated, removed with its mission via a Missions beforeDelete hook) plus an opt-in classification.planningMapEnabled flag on missions. - The mission page gains a planning section: place and edit Arma markers (icons, ellipses, rectangles, polylines) with debounced saves, an SQF copy export, and an Arma grid readout. Everyone who can read the mission sees the plan once enabled; editing requires missions:update or mission ownership. - Base layers come from GET /api/map-features?layers=base, which skips the structure, node and shipment queries; marker icons render tinted by color exactly like in-game. - Includes the hand-written migration, the missionMap service lib, server actions emitting mission:map-marker-* events, and integration plus e2e coverage.
172 lines
7.1 KiB
TypeScript
172 lines
7.1 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
/**
|
|
* Mission planning map: an editor enables the map on a mission, places an
|
|
* Arma icon marker (with text), a hatched rectangle, and a line, edits the
|
|
* selection from the panel, and everything survives a reload. Seeds its own
|
|
* map + mission through cookie-authed REST (needs the test DB's superuser dev).
|
|
*/
|
|
test.describe("Mission planning map", () => {
|
|
let mapId: number | null = null;
|
|
let missionId: number | null = null;
|
|
|
|
async function login(page: Page) {
|
|
const res = await page.request.post("/api/users/login", {
|
|
data: { username: "dev", password: "Test123" },
|
|
});
|
|
expect(res.ok(), `login failed: ${res.status()}`).toBe(true);
|
|
}
|
|
|
|
async function markers(page: Page) {
|
|
const res = await page.request.get(
|
|
`/api/mission-map-markers?where[mission][equals]=${missionId}&depth=0&limit=100&sort=id`,
|
|
);
|
|
expect(res.ok()).toBe(true);
|
|
return ((await res.json()).docs ?? []) as {
|
|
id: number;
|
|
shape: string;
|
|
markerType: string | null;
|
|
color: string;
|
|
brush: string | null;
|
|
text: string | null;
|
|
points: number[][] | null;
|
|
}[];
|
|
}
|
|
|
|
test.afterAll(async ({ browser }) => {
|
|
const page = await browser.newPage();
|
|
await login(page);
|
|
if (missionId) await page.request.delete(`/api/missions/${missionId}`);
|
|
if (mapId) await page.request.delete(`/api/maps/${mapId}`);
|
|
await page.close();
|
|
});
|
|
|
|
test("enable, place icon/area/line, edit, persist", async ({ page }) => {
|
|
await login(page);
|
|
// Tours would overlay the page; opt this browser out before any page runs.
|
|
await page.addInitScript(() => window.localStorage.setItem("ptf:tours:opted-out", "1"));
|
|
|
|
const mapRes = await page.request.post("/api/maps", {
|
|
data: {
|
|
name: "E2E Planning Map",
|
|
worldSizeWidth: 8192,
|
|
worldSizeHeight: 8192,
|
|
basemapMode: "image",
|
|
isActive: true,
|
|
},
|
|
});
|
|
expect(mapRes.ok(), await mapRes.text()).toBe(true);
|
|
mapId = (await mapRes.json()).doc.id;
|
|
|
|
const meRes = await page.request.get("/api/users/me");
|
|
const meId = (await meRes.json()).user.id as number;
|
|
const missionRes = await page.request.post("/api/missions", {
|
|
data: {
|
|
name: "E2E Planning Op",
|
|
codeName: "E2E-PLAN",
|
|
summary: "Planning map e2e mission",
|
|
operationType: "main",
|
|
classification: {
|
|
map: mapId,
|
|
missionType: "PvE",
|
|
startDateTime: new Date(Date.now() + 3 * 86_400_000).toISOString(),
|
|
estimatedDuration: 60,
|
|
},
|
|
ownershipAndStatus: { authors: [meId], status: "Scheduled", visibility: "unit" },
|
|
briefing: [],
|
|
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
|
|
missionRoles: { maxPlayers: 32, providedRoles: [] },
|
|
},
|
|
});
|
|
expect(missionRes.ok(), await missionRes.text()).toBe(true);
|
|
missionId = (await missionRes.json()).doc.id;
|
|
|
|
await page.goto(`/intelligence/missions/${missionId}`);
|
|
await page.getByRole("button", { name: "Enable planning map" }).click();
|
|
|
|
const section = page.locator("section[aria-label='Mission planning map']");
|
|
await expect(section).toBeVisible();
|
|
const map = section.locator(".leaflet-container");
|
|
await expect(map).toBeVisible();
|
|
await page.waitForTimeout(1000);
|
|
// Panel edits can scroll the page, so measure the map before every use.
|
|
const at = async (fx: number, fy: number) => {
|
|
await map.scrollIntoViewIfNeeded();
|
|
const box = (await map.boundingBox())!;
|
|
return { x: box.x + box.width * fx, y: box.y + box.height * fy };
|
|
};
|
|
|
|
// 1. NATO icon (OPFOR armor), red, with text.
|
|
await section.getByRole("radio", { name: "Icon" }).click();
|
|
await section.getByRole("button", { name: "Marker type" }).click();
|
|
await page.getByPlaceholder("Search markers...").fill("o_armor");
|
|
await page.locator("button[title*='(o_armor)']").first().click();
|
|
await section.getByRole("button", { name: "Red", exact: true }).click();
|
|
await section.getByPlaceholder("Label shown beside the icon").fill("ENEMY ARMOR");
|
|
let p = await at(0.55, 0.35);
|
|
await page.mouse.click(p.x, p.y);
|
|
await expect(section.locator(".arma-marker-text", { hasText: "ENEMY ARMOR" })).toBeVisible();
|
|
|
|
// 2. Rectangle with the Grid brush, drawn corner to corner.
|
|
await section.getByRole("radio", { name: "Rectangle" }).click();
|
|
await section.getByRole("combobox").click();
|
|
await page.getByRole("option", { name: "Grid", exact: true }).click();
|
|
// Radix Select blocks pointer events on <body> until it finishes closing.
|
|
await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none");
|
|
const a = await at(0.3, 0.5);
|
|
const b = await at(0.5, 0.7);
|
|
await page.mouse.move(a.x, a.y);
|
|
await page.mouse.down();
|
|
await page.mouse.move(b.x, b.y, { steps: 8 });
|
|
await page.mouse.up();
|
|
await expect(section.getByText("Selected // Rectangle")).toBeVisible();
|
|
|
|
// 3. Line: click, click, double-click.
|
|
await section.getByRole("radio", { name: "Line" }).click();
|
|
for (const [fx, fy] of [
|
|
[0.2, 0.3],
|
|
[0.35, 0.25],
|
|
]) {
|
|
p = await at(fx, fy);
|
|
await page.mouse.click(p.x, p.y);
|
|
await page.waitForTimeout(150);
|
|
}
|
|
p = await at(0.45, 0.35);
|
|
await page.mouse.dblclick(p.x, p.y);
|
|
await expect(section.getByText("Selected // Line")).toBeVisible();
|
|
|
|
await expect
|
|
.poll(async () => (await markers(page)).map((m) => m.shape).sort())
|
|
.toEqual(["icon", "polyline", "rectangle"]);
|
|
const saved = await markers(page);
|
|
const icon = saved.find((m) => m.shape === "icon")!;
|
|
expect(icon).toMatchObject({ markerType: "o_armor", color: "ColorRed", text: "ENEMY ARMOR" });
|
|
expect(saved.find((m) => m.shape === "rectangle")?.brush).toBe("Grid");
|
|
expect(saved.find((m) => m.shape === "polyline")?.points).toHaveLength(3);
|
|
|
|
// 4. Select the icon and edit its text from the panel (debounced save).
|
|
await section.getByRole("radio", { name: "Select" }).click();
|
|
await section.locator(".arma-marker-text", { hasText: "ENEMY ARMOR" }).click();
|
|
await expect(section.getByText("Selected // Icon")).toBeVisible();
|
|
await section.getByPlaceholder("Label shown beside the icon").fill("T-72 PLT");
|
|
await expect
|
|
.poll(async () => (await markers(page)).find((m) => m.id === icon.id)?.text)
|
|
.toBe("T-72 PLT");
|
|
|
|
await section.screenshot({ path: "test-results/mission-planning-map.png" });
|
|
|
|
// 5. Reload: the plan persists and the readout counts it.
|
|
await page.reload();
|
|
const reloaded = page.locator("section[aria-label='Mission planning map']");
|
|
await expect(reloaded.locator(".arma-marker-text", { hasText: "T-72 PLT" })).toBeVisible();
|
|
const footer = reloaded.locator("div.border-t.bg-black\\/60");
|
|
await expect(footer).toContainText("1Icons");
|
|
await expect(footer).toContainText("1Areas");
|
|
await expect(footer).toContainText("1Lines");
|
|
|
|
// 6. Delete key removes the selected marker.
|
|
await reloaded.locator(".arma-marker-text", { hasText: "T-72 PLT" }).click();
|
|
await page.keyboard.press("Delete");
|
|
await expect.poll(async () => (await markers(page)).length).toBe(2);
|
|
});
|
|
});
|