1439 lines
48 KiB
TypeScript
1439 lines
48 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,
|
|
};
|
|
}
|
|
|
|
function draftAndPublishedMapState(
|
|
draftObjects: Array<Record<string, unknown>>,
|
|
publishedObjects: 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: 3,
|
|
};
|
|
const published = {
|
|
...draft,
|
|
id: 901,
|
|
status: "published",
|
|
version: 2,
|
|
};
|
|
|
|
return {
|
|
...noMapState(canManage),
|
|
map: draft,
|
|
draft_map: draft,
|
|
published_map: published,
|
|
objects: draftObjects,
|
|
draft_objects: draftObjects,
|
|
published_objects: publishedObjects,
|
|
};
|
|
}
|
|
|
|
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 From 2 Zones" })).toBeVisible();
|
|
await expect(page.getByLabel("Map controls")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Create From 2 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();
|
|
await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
|
|
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();
|
|
await expect(resizeHandle).toHaveAttribute("width", "64");
|
|
await expect(resizeHandle).toHaveAttribute("height", "64");
|
|
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 expect(page.locator(".location-map-sheet-header strong")).toHaveText("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 page.getByRole("button", { name: "Back" }).click();
|
|
await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Cancel" }).click();
|
|
await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/);
|
|
|
|
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.goto("/login");
|
|
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
|
|
|
|
await page.goto("/stores/100/locations/10/map");
|
|
await expect(page.locator(".location-map-status")).toHaveText("Draft");
|
|
await expect(page.getByRole("heading", { name: "Draft Map" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Continue Editing" }).click();
|
|
await expect(page.locator(".location-map-object", { hasText: "Bread Wall" })).toBeVisible();
|
|
await expect(page.locator(".location-map-object", { hasText: "Temporary Freezer" })).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Publish", exact: true }).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.locator(".location-map-sheet-count")).toHaveText("2 items");
|
|
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("admin setup explains when no zones exist yet", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
await page.route("**/households/1/locations/10/map", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
...noMapState(true),
|
|
zones: [],
|
|
items: [],
|
|
unmapped_count: 0,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.goto("/stores/100/locations/10/map");
|
|
|
|
await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
|
|
await expect(page.getByText("No zones are available yet.")).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Create From Zones" })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: "Create Blank Map" })).toBeEnabled();
|
|
const blankButtonBox = await page.getByRole("button", { name: "Create Blank Map" }).boundingBox();
|
|
const fromZonesButtonBox = await page.getByRole("button", { name: "Create From Zones" }).boundingBox();
|
|
expect(blankButtonBox).not.toBeNull();
|
|
expect(fromZonesButtonBox).not.toBeNull();
|
|
if (!blankButtonBox || !fromZonesButtonBox) throw new Error("No-zone setup actions were not measurable");
|
|
expect(blankButtonBox.y).toBeLessThan(fromZonesButtonBox.y);
|
|
await expect(page.getByLabel("Map controls")).toHaveCount(0);
|
|
});
|
|
|
|
test("admin status follows the visible map when a saved draft exists", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const publishedObjects = [
|
|
{
|
|
id: 1201,
|
|
location_map_id: 901,
|
|
zone_id: 501,
|
|
zone_name: "Bakery",
|
|
type: "zone",
|
|
label: "Live Bakery",
|
|
x: 40,
|
|
y: 40,
|
|
width: 260,
|
|
height: 160,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 1,
|
|
},
|
|
];
|
|
const draftObjects = [
|
|
{
|
|
...publishedObjects[0],
|
|
id: 1301,
|
|
location_map_id: 900,
|
|
label: "Draft Bakery",
|
|
x: 80,
|
|
y: 80,
|
|
},
|
|
];
|
|
|
|
const mapState = draftAndPublishedMapState(draftObjects, publishedObjects, true);
|
|
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.locator(".location-map-status")).toHaveText("Published");
|
|
await expect(page.locator(".location-map-object", { hasText: "Live Bakery" })).toBeVisible();
|
|
await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Edit Draft" }).click();
|
|
await expect(page.locator(".location-map-status")).toHaveText("Draft");
|
|
await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Preview Draft" }).click();
|
|
await expect(page.locator(".location-map-status")).toHaveText("Draft Preview");
|
|
await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "View" }).click();
|
|
await expect(page.locator(".location-map-status")).toHaveText("Published");
|
|
await expect(page.locator(".location-map-object", { hasText: "Live Bakery" })).toBeVisible();
|
|
});
|
|
|
|
test("mobile keeps draft preview status compact in the topbar", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await mockMapShell(page);
|
|
|
|
const publishedObjects = [
|
|
{
|
|
id: 1251,
|
|
location_map_id: 901,
|
|
zone_id: 501,
|
|
zone_name: "Bakery",
|
|
type: "zone",
|
|
label: "Live Bakery",
|
|
x: 40,
|
|
y: 40,
|
|
width: 260,
|
|
height: 160,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 1,
|
|
},
|
|
];
|
|
const draftObjects = [
|
|
{
|
|
...publishedObjects[0],
|
|
id: 1252,
|
|
location_map_id: 900,
|
|
label: "Draft Bakery",
|
|
x: 80,
|
|
y: 80,
|
|
},
|
|
];
|
|
|
|
await page.route("**/households/1/locations/10/map", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(draftAndPublishedMapState(draftObjects, publishedObjects, true)),
|
|
});
|
|
});
|
|
|
|
await page.goto("/stores/100/locations/10/map");
|
|
await page.getByRole("button", { name: "Edit Draft" }).click();
|
|
await page.getByRole("button", { name: "Preview Draft" }).click();
|
|
|
|
const topbar = page.locator(".location-map-topbar");
|
|
const status = page.locator(".location-map-status");
|
|
const toolbar = page.locator(".location-map-toolbar");
|
|
const modeButtons = page.locator(".location-map-mode-buttons");
|
|
const zoomControls = page.locator(".location-map-zoom-controls");
|
|
await expect(status).toHaveText("Draft Preview");
|
|
|
|
const topbarBox = await topbar.boundingBox();
|
|
const titleBox = await page.locator(".location-map-title").boundingBox();
|
|
const statusBox = await status.boundingBox();
|
|
const toolbarBox = await toolbar.boundingBox();
|
|
const modeBox = await modeButtons.boundingBox();
|
|
const zoomBox = await zoomControls.boundingBox();
|
|
expect(topbarBox).not.toBeNull();
|
|
expect(titleBox).not.toBeNull();
|
|
expect(statusBox).not.toBeNull();
|
|
expect(toolbarBox).not.toBeNull();
|
|
expect(modeBox).not.toBeNull();
|
|
expect(zoomBox).not.toBeNull();
|
|
if (!topbarBox || !titleBox || !statusBox || !toolbarBox || !modeBox || !zoomBox) {
|
|
throw new Error("Mobile map controls layout was not measurable");
|
|
}
|
|
expect(topbarBox.height).toBeLessThanOrEqual(66);
|
|
expect(statusBox.y).toBeLessThan(titleBox.y + titleBox.height);
|
|
expect(statusBox.x).toBeGreaterThan(titleBox.x);
|
|
expect(toolbarBox.height).toBeLessThanOrEqual(64);
|
|
expect(zoomBox.y).toBeLessThan(modeBox.y + modeBox.height);
|
|
});
|
|
|
|
test("admin selecting an object does not mark a draft dirty until it changes", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const mapState = draftMapState([
|
|
{
|
|
id: 1351,
|
|
location_map_id: 900,
|
|
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,
|
|
},
|
|
], true);
|
|
|
|
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 page.getByRole("button", { name: "Continue Editing" }).click();
|
|
await page.getByRole("button", { name: "Edit Objects" }).click();
|
|
const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first();
|
|
await bakeryObject.click();
|
|
|
|
await expect(page.getByRole("textbox", { name: "Label" })).toBeVisible();
|
|
await expect(page.locator(".location-map-status")).toHaveText("Draft");
|
|
await expect(page.getByRole("button", { name: "Save Draft" })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled();
|
|
|
|
const objectBox = await bakeryObject.boundingBox();
|
|
expect(objectBox).not.toBeNull();
|
|
if (!objectBox) throw new Error("Bakery map object was not measurable");
|
|
await page.mouse.move(objectBox.x + objectBox.width / 2, objectBox.y + objectBox.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(objectBox.x + objectBox.width / 2 + 50, objectBox.y + objectBox.height / 2 + 30, {
|
|
steps: 8,
|
|
});
|
|
await page.mouse.up();
|
|
|
|
await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
|
|
await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
|
|
await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
|
|
});
|
|
|
|
test("admin nudges selected map areas with arrow keys", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const mapState = draftMapState([
|
|
{
|
|
id: 1352,
|
|
location_map_id: 900,
|
|
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,
|
|
},
|
|
], true);
|
|
|
|
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 page.getByRole("button", { name: "Continue Editing" }).click();
|
|
await page.getByRole("button", { name: "Edit Objects" }).click();
|
|
const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
|
|
await bakeryArea.focus();
|
|
await expect(bakeryArea).toBeFocused();
|
|
await page.keyboard.press("Enter");
|
|
await expect(page.getByRole("spinbutton", { name: "X" })).toHaveValue("40");
|
|
await expect(page.getByRole("spinbutton", { name: "Y" })).toHaveValue("40");
|
|
await expect(page.locator(".location-map-status")).toHaveText("Draft");
|
|
|
|
await page.keyboard.press("ArrowRight");
|
|
await expect(page.getByRole("spinbutton", { name: "X" })).toHaveValue("50");
|
|
await expect(page.getByRole("spinbutton", { name: "Y" })).toHaveValue("40");
|
|
await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
|
|
await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
|
|
await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
|
|
|
|
await page.keyboard.press("Shift+ArrowDown");
|
|
await expect(page.getByRole("spinbutton", { name: "X" })).toHaveValue("50");
|
|
await expect(page.getByRole("spinbutton", { name: "Y" })).toHaveValue("65");
|
|
});
|
|
|
|
test("admin object numeric fields expose map bounds and clamp edits", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const mapState = draftMapState([
|
|
{
|
|
id: 1352,
|
|
location_map_id: 900,
|
|
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,
|
|
},
|
|
], true);
|
|
|
|
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 page.getByRole("button", { name: "Continue Editing" }).click();
|
|
await page.getByRole("button", { name: "Edit Objects" }).click();
|
|
await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
|
|
|
|
const xInput = page.getByRole("spinbutton", { name: "X" });
|
|
const yInput = page.getByRole("spinbutton", { name: "Y" });
|
|
const widthInput = page.getByRole("spinbutton", { name: "W" });
|
|
const heightInput = page.getByRole("spinbutton", { name: "H" });
|
|
|
|
await expect(xInput).toHaveAttribute("min", "0");
|
|
await expect(xInput).toHaveAttribute("max", "740");
|
|
await expect(yInput).toHaveAttribute("max", "540");
|
|
await expect(widthInput).toHaveAttribute("min", "40");
|
|
await expect(widthInput).toHaveAttribute("max", "1000");
|
|
await expect(heightInput).toHaveAttribute("max", "700");
|
|
|
|
await xInput.fill("9999");
|
|
await expect(xInput).toHaveValue("740");
|
|
|
|
await widthInput.fill("5000");
|
|
await expect(widthInput).toHaveValue("1000");
|
|
await expect(xInput).toHaveValue("0");
|
|
});
|
|
|
|
test("admin save progress locks draft controls", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
let mapState = draftMapState([
|
|
{
|
|
id: 1361,
|
|
location_map_id: 900,
|
|
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,
|
|
},
|
|
], true);
|
|
let releaseSave: () => void = () => {};
|
|
let saveStarted = false;
|
|
const saveGate = new Promise<void>((resolve) => {
|
|
releaseSave = resolve;
|
|
});
|
|
|
|
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/draft", async (route) => {
|
|
saveStarted = true;
|
|
const payload = await route.request().postDataJSON();
|
|
await saveGate;
|
|
const savedObjects = (payload.objects as Array<Record<string, unknown>>).map((object, index) => ({
|
|
...(mapState.draft_objects[index] || {}),
|
|
...object,
|
|
}));
|
|
mapState = draftMapState(savedObjects, true);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(mapState),
|
|
});
|
|
});
|
|
|
|
await page.goto("/stores/100/locations/10/map");
|
|
await page.getByRole("button", { name: "Continue Editing" }).click();
|
|
await page.getByRole("button", { name: "Edit Objects" }).click();
|
|
await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
|
|
await page.getByRole("textbox", { name: "Label" }).fill("Saving Bakery");
|
|
|
|
await page.getByRole("button", { name: "Save Draft" }).click();
|
|
await expect.poll(() => saveStarted).toBe(true);
|
|
await expect(page.getByRole("button", { name: "Saving..." })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: "Publish" })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: "Add Area" })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: "Preview Draft" })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: "Duplicate" })).toBeDisabled();
|
|
await expect(page.getByRole("button", { name: "Delete" })).toBeDisabled();
|
|
await expect(page.getByRole("textbox", { name: "Label" })).toBeDisabled();
|
|
|
|
releaseSave();
|
|
await expect(page.locator(".location-map-status")).toHaveText("Draft");
|
|
await expect(page.getByRole("button", { name: "Save Draft" })).toBeDisabled();
|
|
});
|
|
|
|
test("admin hard navigation warns when draft edits are unsaved", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const mapState = draftMapState([
|
|
{
|
|
id: 1371,
|
|
location_map_id: 900,
|
|
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,
|
|
},
|
|
], true);
|
|
|
|
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");
|
|
|
|
const cleanBeforeUnload = await page.evaluate(() => {
|
|
const event = new Event("beforeunload", { cancelable: true });
|
|
const dispatchResult = window.dispatchEvent(event);
|
|
return {
|
|
defaultPrevented: event.defaultPrevented,
|
|
dispatchResult,
|
|
};
|
|
});
|
|
expect(cleanBeforeUnload).toEqual({
|
|
defaultPrevented: false,
|
|
dispatchResult: true,
|
|
});
|
|
|
|
await page.getByRole("button", { name: "Continue Editing" }).click();
|
|
await page.getByRole("button", { name: "Edit Objects" }).click();
|
|
await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
|
|
await page.getByRole("textbox", { name: "Label" }).fill("Hard Navigation Bakery");
|
|
|
|
const dirtyBeforeUnload = await page.evaluate(() => {
|
|
const event = new Event("beforeunload", { cancelable: true });
|
|
const dispatchResult = window.dispatchEvent(event);
|
|
return {
|
|
defaultPrevented: event.defaultPrevented,
|
|
dispatchResult,
|
|
};
|
|
});
|
|
expect(dirtyBeforeUnload).toEqual({
|
|
defaultPrevented: true,
|
|
dispatchResult: false,
|
|
});
|
|
});
|
|
|
|
test("admin publish saves pending map edits before publishing", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
let mapState = publishedMapState([
|
|
{
|
|
id: 1401,
|
|
location_map_id: 901,
|
|
zone_id: 501,
|
|
zone_name: "Bakery",
|
|
type: "zone",
|
|
label: "Live Bakery",
|
|
x: 40,
|
|
y: 40,
|
|
width: 260,
|
|
height: 160,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 1,
|
|
},
|
|
], true);
|
|
let savedPayload: Record<string, unknown> | null = null;
|
|
let publishCalled = 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.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: 1450 + index,
|
|
location_map_id: 900,
|
|
zone_name: object.zone_id === 501 ? "Bakery" : null,
|
|
...object,
|
|
}));
|
|
mapState = draftAndPublishedMapState(savedObjects, mapState.published_objects, true);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(mapState),
|
|
});
|
|
});
|
|
|
|
await page.route("**/households/1/locations/10/map/publish", async (route) => {
|
|
publishCalled = true;
|
|
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 page.getByRole("button", { name: "Edit Map" }).click();
|
|
await page.getByRole("button", { name: "Edit Objects" }).click();
|
|
await page.locator(".location-map-object", { hasText: "Live Bakery" }).locator("rect").first().click();
|
|
await page.getByRole("textbox", { name: "Label" }).fill("Quick Publish Bakery");
|
|
await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
|
|
|
|
await page.getByRole("button", { name: "Publish", exact: true }).click();
|
|
|
|
await expect.poll(() => publishCalled).toBe(true);
|
|
expect(savedPayload).not.toBeNull();
|
|
expect(savedPayload?.objects).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
label: "Quick Publish Bakery",
|
|
zone_id: 501,
|
|
}),
|
|
])
|
|
);
|
|
await expect(page.locator(".location-map-status")).toHaveText("Published");
|
|
await expect(page.getByRole("button", { name: "Map area Quick Publish Bakery" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Map area Live Bakery" })).toHaveCount(0);
|
|
});
|
|
|
|
test("viewer wraps long zone labels and keeps item counts visible", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const mapState = publishedMapState([
|
|
{
|
|
id: 1501,
|
|
location_map_id: 901,
|
|
zone_id: 501,
|
|
zone_name: "Produce & Fresh Vegetables",
|
|
type: "zone",
|
|
label: "Produce & Fresh Vegetables",
|
|
x: 40,
|
|
y: 40,
|
|
width: 260,
|
|
height: 120,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 1,
|
|
},
|
|
], true);
|
|
|
|
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");
|
|
|
|
const produceArea = page.locator('[data-object-key="1501"]');
|
|
await expect(produceArea.locator(".location-map-label tspan")).toHaveCount(2);
|
|
await expect(produceArea.locator(".location-map-label tspan").nth(0)).toHaveText("Produce & Fresh");
|
|
await expect(produceArea.locator(".location-map-label tspan").nth(1)).toHaveText("Vegetables");
|
|
await expect(produceArea.locator(".location-map-count")).toHaveText("2 items");
|
|
await expect(produceArea.locator(".location-map-count")).toHaveAttribute("y", "116");
|
|
await expect(page.locator(".location-map-pin")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Layers" }).click();
|
|
await page.getByLabel("Labels").uncheck();
|
|
await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toBeVisible();
|
|
await expect(produceArea.locator(".location-map-label")).toHaveCount(0);
|
|
await expect(produceArea.locator(".location-map-count")).toHaveText("2 items");
|
|
await expect(produceArea.locator(".location-map-count")).toHaveAttribute("y", "92");
|
|
|
|
await page.getByRole("button", { name: "Reset" }).click();
|
|
await expect(page.getByRole("button", { name: "Layers" })).toBeVisible();
|
|
await expect(produceArea.locator(".location-map-label tspan")).toHaveCount(2);
|
|
await expect(produceArea.locator(".location-map-count")).toHaveAttribute("y", "116");
|
|
});
|
|
|
|
test("viewer explains when the zones layer hides the map", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const mapState = publishedMapState([
|
|
{
|
|
id: 1521,
|
|
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,
|
|
},
|
|
], true);
|
|
|
|
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.getByRole("button", { name: "Map area Bakery" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Layers" }).click();
|
|
await page.getByLabel("Zones").uncheck();
|
|
|
|
await expect(page.getByRole("button", { name: "Map area Bakery" })).toHaveCount(0);
|
|
await expect(page.getByText("Zones hidden in Layers")).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Show Zones" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Show Zones" }).click();
|
|
await expect(page.getByRole("button", { name: "Map area Bakery" })).toBeVisible();
|
|
await expect(page.getByText("Zones hidden in Layers")).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Layers" })).toBeVisible();
|
|
});
|
|
|
|
test("viewer shows a compact overflow cue for long unmapped lists", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const unmappedItems = Array.from({ length: 11 }, (_, index) => ({
|
|
id: 4000 + index,
|
|
item_name: `unmapped item ${index + 1}`,
|
|
quantity: 1,
|
|
bought: false,
|
|
zone_id: null,
|
|
zone: null,
|
|
added_by_users: ["map-user"],
|
|
}));
|
|
const mapState = {
|
|
...publishedMapState([], true),
|
|
items: unmappedItems,
|
|
unmapped_count: unmappedItems.length,
|
|
};
|
|
|
|
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 page.getByRole("button", { name: "Layers" }).click();
|
|
await page.getByLabel("Unmapped").check();
|
|
|
|
await expect(page.getByText("unmapped item 1")).toBeVisible();
|
|
await expect(page.getByText("unmapped item 8")).toBeVisible();
|
|
await expect(page.getByText("unmapped item 9")).toHaveCount(0);
|
|
await expect(page.getByLabel("3 more unmapped items")).toHaveText("+3 more");
|
|
});
|
|
|
|
test("viewer explains when selected zone items are hidden by filters", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const mapState = publishedMapState([
|
|
{
|
|
id: 1551,
|
|
location_map_id: 901,
|
|
zone_id: 502,
|
|
zone_name: "Frozen Foods",
|
|
type: "zone",
|
|
label: "Frozen Foods",
|
|
x: 40,
|
|
y: 40,
|
|
width: 260,
|
|
height: 160,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 1,
|
|
},
|
|
], true);
|
|
|
|
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");
|
|
|
|
const frozenArea = page.locator('[data-object-key="1551"]');
|
|
await expect(frozenArea.locator(".location-map-count")).toHaveText("0 items");
|
|
await expect(page.locator(".location-map-pin")).toHaveCount(0);
|
|
|
|
await frozenArea.locator("rect").first().click();
|
|
await expect(page.locator(".location-map-sheet-count")).toHaveText("0 shown");
|
|
await expect(page.getByText("Items are assigned here, but hidden by layer filters.")).toBeVisible();
|
|
await expect(page.getByText("frozen salmon")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Show Zone Items" }).click();
|
|
await expect(page.locator(".location-map-sheet-count")).toHaveText("1 item");
|
|
await expect(page.getByText("frozen salmon")).toBeVisible();
|
|
await expect(frozenArea.locator(".location-map-count")).toHaveText("1 item");
|
|
await expect(page.locator(".location-map-pin")).toHaveCount(0);
|
|
});
|
|
|
|
test("viewer handles empty and many-item zone panels without default pins", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const manyItems = Array.from({ length: 14 }, (_, index) => ({
|
|
id: 3000 + index,
|
|
item_name: `bulk item ${index + 1}`,
|
|
quantity: index + 1,
|
|
bought: false,
|
|
zone_id: 501,
|
|
zone: "Bakery",
|
|
added_by_users: ["map-user"],
|
|
}));
|
|
const mapState = {
|
|
...publishedMapState([
|
|
{
|
|
id: 1561,
|
|
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,
|
|
},
|
|
{
|
|
id: 1562,
|
|
location_map_id: 901,
|
|
zone_id: 502,
|
|
zone_name: "Frozen Foods",
|
|
type: "zone",
|
|
label: "Frozen Foods",
|
|
x: 340,
|
|
y: 40,
|
|
width: 260,
|
|
height: 160,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 2,
|
|
},
|
|
], true),
|
|
items: manyItems,
|
|
unmapped_count: 0,
|
|
};
|
|
|
|
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.locator(".location-map-pin")).toHaveCount(0);
|
|
|
|
await page.locator('[data-object-key="1562"]').locator("rect").first().click();
|
|
await expect(page.locator(".location-map-sheet-count")).toHaveText("0 items");
|
|
await expect(page.getByText("No items assigned to this zone yet.")).toBeVisible();
|
|
|
|
await page.locator('[data-object-key="1561"]').locator("rect").first().click();
|
|
await expect(page.locator(".location-map-sheet-count")).toHaveText("14 items");
|
|
await expect(page.locator(".location-map-zone-items li")).toHaveCount(14);
|
|
await expect(page.locator(".location-map-zone-items li").first()).toContainText("bulk item 1");
|
|
await expect(page.locator(".location-map-zone-items li").last()).toContainText("bulk item 14");
|
|
await expect(page.locator(".location-map-pin")).toHaveCount(0);
|
|
});
|
|
|
|
test("viewer map areas are keyboard selectable", async ({ page }) => {
|
|
await mockMapShell(page);
|
|
|
|
const mapState = publishedMapState([
|
|
{
|
|
id: 1571,
|
|
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,
|
|
},
|
|
{
|
|
id: 1572,
|
|
location_map_id: 901,
|
|
zone_id: 502,
|
|
zone_name: "Frozen Foods",
|
|
type: "zone",
|
|
label: "Frozen Foods",
|
|
x: 340,
|
|
y: 40,
|
|
width: 260,
|
|
height: 160,
|
|
rotation: 0,
|
|
locked: false,
|
|
visible: true,
|
|
sort_order: 2,
|
|
},
|
|
], true);
|
|
|
|
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");
|
|
|
|
const bakeryArea = page.getByRole("button", { name: "Map area Bakery" });
|
|
const frozenArea = page.getByRole("button", { name: "Map area Frozen Foods" });
|
|
await expect(bakeryArea).toHaveAttribute("aria-pressed", "false");
|
|
|
|
await bakeryArea.focus();
|
|
await expect(bakeryArea).toBeFocused();
|
|
await page.keyboard.press("Enter");
|
|
await expect(bakeryArea).toHaveAttribute("aria-pressed", "true");
|
|
await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
|
|
await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items");
|
|
await expect(page.getByText("french bread")).toBeVisible();
|
|
|
|
await frozenArea.focus();
|
|
await expect(frozenArea).toBeFocused();
|
|
await page.keyboard.press("Space");
|
|
await expect(frozenArea).toHaveAttribute("aria-pressed", "true");
|
|
await expect(bakeryArea).toHaveAttribute("aria-pressed", "false");
|
|
await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Frozen Foods");
|
|
await expect(page.locator(".location-map-sheet-count")).toHaveText("0 shown");
|
|
});
|
|
|
|
test("mobile fit zoom fits the map into the visible canvas", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await mockMapShell(page);
|
|
|
|
const mapState = publishedMapState([
|
|
{
|
|
id: 1581,
|
|
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,
|
|
},
|
|
], true);
|
|
|
|
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 page.getByRole("button", { name: "Fit" }).click();
|
|
|
|
const zoomText = page.locator(".location-map-zoom-controls span");
|
|
await expect.poll(async () => {
|
|
const value = await zoomText.textContent();
|
|
return Number(value?.replace("%", "") || 0);
|
|
}).toBeLessThan(45);
|
|
|
|
const scrollBox = await page.locator(".location-map-scroll").boundingBox();
|
|
const svgBox = await page.locator(".location-map-svg").boundingBox();
|
|
expect(scrollBox).not.toBeNull();
|
|
expect(svgBox).not.toBeNull();
|
|
if (!scrollBox || !svgBox) throw new Error("Mobile fitted map was not measurable");
|
|
expect(svgBox.width).toBeLessThanOrEqual(scrollBox.width + 1);
|
|
expect(svgBox.height).toBeLessThanOrEqual(scrollBox.height + 1);
|
|
});
|
|
|
|
test("mobile editor uses bottom sheet controls instead of desktop object toolbar", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await mockMapShell(page);
|
|
|
|
const mapState = draftMapState([
|
|
{
|
|
id: 1601,
|
|
location_map_id: 900,
|
|
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,
|
|
},
|
|
], true);
|
|
|
|
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.getByRole("heading", { name: "Costco" })).toBeVisible();
|
|
await expect(page.getByText("Eastvale")).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Draft Map" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Continue Editing" }).click();
|
|
|
|
await expect(page.getByLabel("Map controls")).toBeVisible();
|
|
await expect(page.locator(".location-map-tool-buttons")).toBeHidden();
|
|
await expect(page.locator(".location-map-mobile-tool-switch")).toBeVisible();
|
|
const primaryActions = page.locator(".location-map-primary-actions");
|
|
const secondaryActions = page.locator(".location-map-secondary-actions");
|
|
await expect(primaryActions.getByRole("button", { name: "Save Draft" })).toBeVisible();
|
|
await expect(primaryActions.getByRole("button", { name: "Publish" })).toBeVisible();
|
|
await expect(secondaryActions.getByRole("button", { name: "Add Area" })).toBeVisible();
|
|
await expect(secondaryActions.getByRole("button", { name: "Preview Draft" })).toBeVisible();
|
|
const primaryActionBox = await primaryActions.boundingBox();
|
|
const secondaryActionBox = await secondaryActions.boundingBox();
|
|
expect(primaryActionBox).not.toBeNull();
|
|
expect(secondaryActionBox).not.toBeNull();
|
|
if (!primaryActionBox || !secondaryActionBox) throw new Error("Mobile action groups were not measurable");
|
|
expect(primaryActionBox.y).toBeLessThan(secondaryActionBox.y);
|
|
await expect(page.getByRole("button", { name: "Pan" })).toHaveClass(/active/);
|
|
await expect(page.getByRole("button", { name: "Pan" })).toHaveClass(/is-pan/);
|
|
await expect(page.getByRole("button", { name: "Objects" })).toHaveClass(/is-edit/);
|
|
await expect(page.locator(".location-map-pin")).toHaveCount(0);
|
|
|
|
const canvasBox = await page.locator(".location-map-canvas-shell").boundingBox();
|
|
const sheetBox = await page.locator(".location-map-bottom-sheet").boundingBox();
|
|
expect(canvasBox).not.toBeNull();
|
|
expect(sheetBox).not.toBeNull();
|
|
if (!canvasBox || !sheetBox) throw new Error("Mobile map layout was not measurable");
|
|
expect(canvasBox.height).toBeGreaterThan(260);
|
|
expect(sheetBox.height).toBeLessThanOrEqual(360);
|
|
|
|
await page.getByRole("button", { name: "Objects" }).click();
|
|
await expect(page.getByRole("button", { name: "Objects" })).toHaveClass(/active/);
|
|
await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click();
|
|
await expect(page.getByRole("textbox", { name: "Label" })).toBeVisible();
|
|
await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery");
|
|
await page.getByRole("button", { name: "Done" }).click();
|
|
await expect(page.getByRole("textbox", { name: "Label" })).toHaveCount(0);
|
|
await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Edit Objects");
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test("members see a clean empty state when no map is published", async ({ page }) => {
|
|
await mockMapShell(page, memberHousehold);
|
|
|
|
await page.route("**/households/1/locations/10/map", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(noMapState(false)),
|
|
});
|
|
});
|
|
|
|
await page.goto("/stores/100/locations/10/map");
|
|
|
|
await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible();
|
|
await expect(page.getByText("A household admin has not published a map yet.")).toBeVisible();
|
|
await expect(page.getByText("Create a rough map for this store location.")).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: /Create From/ })).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Create Blank Map" })).toHaveCount(0);
|
|
await expect(page.getByLabel("Map controls")).toHaveCount(0);
|
|
});
|