361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
import { confirmSlide, mockConfig, seedAuthStorage } from "./helpers/e2e";
|
|
|
|
const adminHousehold = { id: 1, name: "Map House", role: "admin", invite_code: "ABCD1234" };
|
|
const memberHousehold = { ...adminHousehold, role: "member" };
|
|
const storeLocation = {
|
|
id: 10,
|
|
household_store_id: 100,
|
|
household_id: 1,
|
|
name: "Costco",
|
|
location_name: "Eastvale",
|
|
display_name: "Costco - Eastvale",
|
|
is_default: true,
|
|
};
|
|
const zones = [
|
|
{ id: 501, name: "Bakery", sort_order: 10, item_count: 2 },
|
|
{ id: 502, name: "Frozen Foods", sort_order: 20, item_count: 1 },
|
|
];
|
|
const items = [
|
|
{
|
|
id: 1,
|
|
item_name: "french bread",
|
|
quantity: 1,
|
|
bought: false,
|
|
zone_id: 501,
|
|
zone: "Bakery",
|
|
added_by_users: ["map-user"],
|
|
},
|
|
{
|
|
id: 2,
|
|
item_name: "sourdough",
|
|
quantity: 1,
|
|
bought: false,
|
|
zone_id: 501,
|
|
zone: "Bakery",
|
|
added_by_users: ["map-user"],
|
|
},
|
|
{
|
|
id: 3,
|
|
item_name: "frozen salmon",
|
|
quantity: 1,
|
|
bought: false,
|
|
zone_id: 502,
|
|
zone: "Frozen Foods",
|
|
added_by_users: ["other-user"],
|
|
},
|
|
{
|
|
id: 4,
|
|
item_name: "loose batteries",
|
|
quantity: 1,
|
|
bought: false,
|
|
zone_id: null,
|
|
zone: null,
|
|
added_by_users: ["map-user"],
|
|
},
|
|
];
|
|
|
|
function noMapState(canManage = true) {
|
|
return {
|
|
location: storeLocation,
|
|
map: null,
|
|
draft_map: null,
|
|
published_map: null,
|
|
objects: [],
|
|
draft_objects: [],
|
|
published_objects: [],
|
|
zones,
|
|
items,
|
|
unmapped_count: 1,
|
|
can_manage: canManage,
|
|
};
|
|
}
|
|
|
|
function draftMapState(objects: Array<Record<string, unknown>>, canManage = true) {
|
|
const draft = {
|
|
id: 900,
|
|
household_id: 1,
|
|
store_location_id: 10,
|
|
name: "Store Map",
|
|
width: 1000,
|
|
height: 700,
|
|
status: "draft",
|
|
version: 1,
|
|
};
|
|
|
|
return {
|
|
...noMapState(canManage),
|
|
map: draft,
|
|
draft_map: draft,
|
|
objects,
|
|
draft_objects: objects,
|
|
};
|
|
}
|
|
|
|
function publishedMapState(objects: Array<Record<string, unknown>>, canManage = true) {
|
|
const published = {
|
|
id: 901,
|
|
household_id: 1,
|
|
store_location_id: 10,
|
|
name: "Store Map",
|
|
width: 1000,
|
|
height: 700,
|
|
status: "published",
|
|
version: 2,
|
|
};
|
|
|
|
return {
|
|
...noMapState(canManage),
|
|
map: published,
|
|
published_map: published,
|
|
objects,
|
|
published_objects: objects,
|
|
};
|
|
}
|
|
|
|
async function mockMapShell(page: Page, household = adminHousehold) {
|
|
await seedAuthStorage(page, { username: "map-user", role: household.role });
|
|
await mockConfig(page);
|
|
|
|
await page.route("**/households", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify([household]),
|
|
});
|
|
});
|
|
|
|
await page.route("**/households/1/stores", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify([storeLocation]),
|
|
});
|
|
});
|
|
|
|
await page.route("**/households/1/locations/10/zones", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ zones }),
|
|
});
|
|
});
|
|
}
|
|
|
|
test("admin creates a map from zones, saves a draft, and publishes it", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
let mapState = noMapState(true);
|
|
let savedPayload: Record<string, unknown> | null = null;
|
|
|
|
await page.route("**/households/1/locations/10/map", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(mapState),
|
|
});
|
|
});
|
|
|
|
await page.route("**/households/1/locations/10/map/from-zones", async (route) => {
|
|
const objects = [
|
|
{
|
|
id: 1001,
|
|
location_map_id: 900,
|
|
zone_id: 501,
|
|
zone_name: "Bakery",
|
|
type: "zone",
|
|
label: "Bakery",
|
|
x: 28,
|
|
y: 28,
|
|
width: 300,
|
|
height: 180,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 1,
|
|
},
|
|
{
|
|
id: 1002,
|
|
location_map_id: 900,
|
|
zone_id: 502,
|
|
zone_name: "Frozen Foods",
|
|
type: "zone",
|
|
label: "Frozen Foods",
|
|
x: 356,
|
|
y: 28,
|
|
width: 300,
|
|
height: 180,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 2,
|
|
},
|
|
];
|
|
mapState = draftMapState(objects, true);
|
|
await route.fulfill({
|
|
status: 201,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(mapState),
|
|
});
|
|
});
|
|
|
|
await page.route("**/households/1/locations/10/map/draft", async (route) => {
|
|
savedPayload = await route.request().postDataJSON();
|
|
const savedObjects = (savedPayload.objects as Array<Record<string, unknown>>).map((object, index) => ({
|
|
id: 1100 + index,
|
|
location_map_id: 900,
|
|
zone_name: object.zone_id === 501 ? "Bakery" : "Frozen Foods",
|
|
...object,
|
|
}));
|
|
mapState = draftMapState(savedObjects, true);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(mapState),
|
|
});
|
|
});
|
|
|
|
await page.route("**/households/1/locations/10/map/publish", async (route) => {
|
|
mapState = publishedMapState(mapState.draft_objects, true);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(mapState),
|
|
});
|
|
});
|
|
|
|
await page.goto("/stores/100/locations/10/map");
|
|
|
|
await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible();
|
|
await expect(page.getByText("Eastvale")).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Create Map From Existing Zones" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Create Map From Existing Zones" }).click();
|
|
await expect(page.getByText("Bakery")).toBeVisible();
|
|
await expect(page.getByText("Frozen Foods")).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Pan Mode" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Edit Objects" })).toBeVisible();
|
|
await expect(page.locator(".location-map-pin")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Pan Mode" }).click();
|
|
const bakeryPanObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
|
|
const panBox = await bakeryPanObject.boundingBox();
|
|
expect(panBox).not.toBeNull();
|
|
if (!panBox) throw new Error("Bakery map object was not measurable");
|
|
await page.mouse.move(panBox.x + panBox.width / 2, panBox.y + panBox.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(panBox.x + panBox.width / 2 - 80, panBox.y + panBox.height / 2 - 20, {
|
|
steps: 8,
|
|
});
|
|
await page.mouse.up();
|
|
|
|
await page.getByRole("button", { name: "Edit Objects" }).click();
|
|
const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
|
|
await bakeryObject.click();
|
|
const moveBox = await bakeryObject.boundingBox();
|
|
expect(moveBox).not.toBeNull();
|
|
if (!moveBox) throw new Error("Bakery map object could not be moved");
|
|
await page.mouse.move(moveBox.x + moveBox.width / 2, moveBox.y + moveBox.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(moveBox.x + moveBox.width / 2 + 60, moveBox.y + moveBox.height / 2 + 40, {
|
|
steps: 8,
|
|
});
|
|
await page.mouse.up();
|
|
|
|
const resizeHandle = page.locator(".location-map-resize-handle");
|
|
await expect(resizeHandle).toBeVisible();
|
|
const resizeBox = await resizeHandle.boundingBox();
|
|
expect(resizeBox).not.toBeNull();
|
|
if (!resizeBox) throw new Error("Resize handle was not measurable");
|
|
await page.mouse.move(resizeBox.x + resizeBox.width / 2, resizeBox.y + resizeBox.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(resizeBox.x + resizeBox.width / 2 + 45, resizeBox.y + resizeBox.height / 2 + 35, {
|
|
steps: 8,
|
|
});
|
|
await page.mouse.up();
|
|
|
|
await page.getByRole("textbox", { name: "Label" }).fill("Bread Wall");
|
|
await page.getByRole("button", { name: "Duplicate" }).click();
|
|
await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bread Wall Copy");
|
|
await page.getByLabel("Linked zone").selectOption("502");
|
|
await page.getByRole("textbox", { name: "Label" }).fill("Temporary Freezer");
|
|
await page.getByRole("button", { name: "Delete" }).click();
|
|
await confirmSlide(page);
|
|
await expect(page.locator(".location-map-object", { hasText: "Temporary Freezer" })).toHaveCount(0);
|
|
|
|
await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
|
|
await page.getByRole("button", { name: "Preview Draft" }).click();
|
|
await expect(page.locator(".location-map-object", { hasText: "Bread Wall" })).toBeVisible();
|
|
await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
|
|
|
|
await page.getByRole("button", { name: "Edit Draft" }).click();
|
|
await page.getByRole("button", { name: "Edit Objects" }).click();
|
|
await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click();
|
|
await page.getByRole("button", { name: "Save Draft" }).click();
|
|
await expect(page.locator(".location-map-status")).toHaveText("Draft");
|
|
|
|
expect(savedPayload).not.toBeNull();
|
|
const savedObjects = savedPayload?.objects as Array<Record<string, unknown>>;
|
|
const savedBreadWall = savedObjects.find((object) => object.label === "Bread Wall");
|
|
expect(savedBreadWall).toEqual(expect.objectContaining({ zone_id: 501 }));
|
|
expect(Number(savedBreadWall?.x)).toBeGreaterThan(28);
|
|
expect(Number(savedBreadWall?.y)).toBeGreaterThan(28);
|
|
expect(Number(savedBreadWall?.width)).toBeGreaterThan(300);
|
|
expect(Number(savedBreadWall?.height)).toBeGreaterThan(180);
|
|
expect(savedObjects.some((object) => object.label === "Temporary Freezer")).toBe(false);
|
|
|
|
await page.getByRole("button", { name: "Publish" }).click();
|
|
await expect(page.locator(".location-map-status")).toHaveText("Published");
|
|
await expect(page.getByRole("button", { name: "Edit Map" })).toBeVisible();
|
|
await expect(page.getByText("loose batteries")).toHaveCount(0);
|
|
|
|
await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click();
|
|
await expect(page.getByText("french bread")).toBeVisible();
|
|
await expect(page.getByText("sourdough")).toBeVisible();
|
|
await expect(page.getByText("frozen salmon")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Layers" }).click();
|
|
await page.getByLabel("Unmapped").check();
|
|
await expect(page.getByText("loose batteries")).toBeVisible();
|
|
});
|
|
|
|
test("members can view a published map but cannot edit it", async ({ page }) => {
|
|
await mockMapShell(page, memberHousehold);
|
|
|
|
const mapState = publishedMapState([
|
|
{
|
|
id: 1201,
|
|
location_map_id: 901,
|
|
zone_id: 501,
|
|
zone_name: "Bakery",
|
|
type: "zone",
|
|
label: "Bakery",
|
|
x: 40,
|
|
y: 40,
|
|
width: 260,
|
|
height: 160,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 1,
|
|
},
|
|
], false);
|
|
|
|
await page.route("**/households/1/locations/10/map", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(mapState),
|
|
});
|
|
});
|
|
|
|
await page.goto("/stores/100/locations/10/map");
|
|
|
|
await expect(page.getByText("Published")).toBeVisible();
|
|
await expect(page.getByText("Bakery")).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Edit Map" })).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Publish" })).toHaveCount(0);
|
|
});
|