grocery-app/frontend/tests/location-map-manager.spec.ts
2026-06-03 14:52:42 -07:00

977 lines
33 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();
const zonePanel = page.locator(".location-map-zone-items");
await expect(zonePanel.locator(".location-map-zone-items-title span")).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();
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 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(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");
});
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-zone-items-title span")).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-zone-items-title span")).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-zone-items-title span")).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-zone-items-title span")).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("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();
});
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);
});