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 secondStoreLocation = { ...storeLocation, id: 11, location_name: "Ontario", display_name: "Costco - Ontario", }; 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>, 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>, 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>, publishedObjects: Array>, 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: typeof adminHousehold | Array = adminHousehold, locations: Array = [storeLocation] ) { const households = Array.isArray(household) ? household : [household]; const primaryHousehold = households[0] || adminHousehold; await seedAuthStorage(page, { username: "map-user", role: primaryHousehold.role }); await mockConfig(page); await page.route("**/households", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(households), }); }); await page.route("**/households/1/stores", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(locations), }); }); await page.route("**/households/1/locations/10/zones", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ zones }), }); }); } async function expectMapFitsCanvas(page: Page, maxZoomPercent: number, context: string) { const scroll = page.locator(".location-map-scroll"); const zoomText = page.locator(".location-map-zoom-controls span"); await expect.poll(async () => { const value = await zoomText.textContent(); return Number(value?.replace("%", "") || 0); }).toBeLessThan(maxZoomPercent); const scrollBox = await 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(`${context} fitted map was not measurable`); expect(svgBox.width).toBeLessThanOrEqual(scrollBox.width + 1); expect(svgBox.height).toBeLessThanOrEqual(scrollBox.height + 1); return { scroll }; } async function readMapZoomPercent(page: Page) { const value = await page.locator(".location-map-zoom-controls span").textContent(); return Number(value?.replace("%", "") || 0); } async function expectResizeHandleWithinMapScroll(page: Page) { const scroll = page.locator(".location-map-scroll"); const resizeHandle = page.locator(".location-map-resize-handle"); await expect(resizeHandle).toBeVisible(); const scrollBox = await scroll.boundingBox(); const handleBox = await resizeHandle.boundingBox(); expect(scrollBox).not.toBeNull(); expect(handleBox).not.toBeNull(); if (!scrollBox || !handleBox) { throw new Error("Selected resize handle was not measurable"); } expect(handleBox.x).toBeGreaterThanOrEqual(scrollBox.x); expect(handleBox.y).toBeGreaterThanOrEqual(scrollBox.y); expect(handleBox.x + handleBox.width).toBeLessThanOrEqual(scrollBox.x + scrollBox.width + 1); expect(handleBox.y + handleBox.height).toBeLessThanOrEqual(scrollBox.y + scrollBox.height + 1); } 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 | 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>).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: "Move Map" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Edit Areas" })).toHaveCount(0); await expect(page.locator(".location-map-tool-buttons")).toHaveCount(0); await expect(page.locator(".location-map-pin")).toHaveCount(0); 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"); await expect(page.locator(".location-map-object").last()).toHaveAttribute("data-object-key", "1001"); await expect(page.getByLabel("Object type")).toHaveCount(0); 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 area Bread Wall" }).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 area Temporary Freezer" }).click(); await confirmSlide(page); await expect(page.locator(".location-map-object", { hasText: "Temporary Freezer" })).toHaveCount(0); await page.getByRole("button", { name: "Back to grocery list" }).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$/); const mapStatus = page.locator(".location-map-status"); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await expect(mapStatus).toHaveAccessibleName("Map status: Unsaved Draft"); await page.getByRole("button", { name: "View", exact: true }).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.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"); await expect(mapStatus).toHaveAccessibleName("Map status: Draft"); expect(savedPayload).not.toBeNull(); const savedObjects = savedPayload?.objects as Array>; 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 expect(page.getByRole("button", { name: "View Draft" })).toBeVisible(); await expect(page.getByRole("button", { name: "Publish", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Preview Map" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Publish Map" })).toHaveCount(0); 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(mapStatus).toHaveAccessibleName("Map status: Published"); await expect(page.getByRole("button", { name: "Edit Draft" })).toBeVisible(); await expect(page.getByRole("button", { name: "Edit Map" })).toHaveCount(0); 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.getByRole("button", { name: "Unmapped" }).click(); await expect(page.getByText("loose batteries")).toHaveCount(0); await page.locator(".location-map-background").click({ position: { x: 8, y: 8 } }); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Map Details"); await expect(page.getByText("loose batteries")).toBeVisible(); }); test("admin setup uses compact status rows 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(); const setupDetails = page.getByRole("group", { name: "Map setup details" }); await expect(setupDetails.locator(".location-map-setup-row")).toHaveCount(1); await expect(setupDetails.locator(".location-map-setup-row", { hasText: "Zones" })).toContainText("0"); await expect(setupDetails.getByText("Status")).toHaveCount(0); await expect(page.getByText("Not started")).toHaveCount(0); await expect(page.getByText("No zones are available yet.")).toHaveCount(0); await expect(page.getByRole("button", { name: "Create From Zones" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Create Blank Map" })).toBeEnabled(); const blankButtonBox = await page.getByRole("button", { name: "Create Blank Map" }).boundingBox(); expect(blankButtonBox).not.toBeNull(); if (!blankButtonBox) throw new Error("No-zone setup action was not measurable"); expect(blankButtonBox.height).toBeGreaterThanOrEqual(40); await expect(page.getByLabel("Map controls")).toHaveCount(0); }); test("map message prompts for a household with compact direct text", async ({ page }) => { await seedAuthStorage(page, { username: "map-user", role: "member" }); await mockConfig(page); await page.route("**/households", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.getByRole("heading", { name: "Select Household" })).toBeVisible(); const messageCard = page.locator(".location-map-message-card"); await expect(messageCard.getByText("Select a household to manage maps.")).toBeVisible(); await expect(messageCard.locator(".location-map-state-message")).toHaveText("Select a household to manage maps."); await expect(messageCard.getByRole("group", { name: "Map message details" })).toHaveCount(0); await expect(messageCard.locator(".location-map-setup-row", { hasText: "Message" })).toHaveCount(0); await expect(messageCard.locator(".location-map-setup-row", { hasText: "Status" })).toHaveCount(0); await expect(page.getByLabel("Map controls")).toHaveCount(0); }); test("load failure shows retryable error instead of no-map setup", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 391, 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); let attempts = 0; await page.route("**/households/1/locations/10/map", async (route) => { attempts += 1; if (attempts === 1) { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { message: "Map service unavailable" } }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); const errorCard = page.locator(".location-map-load-error"); await expect(errorCard.getByRole("heading", { name: "Map Unavailable" })).toBeVisible(); await expect(errorCard.locator(".location-map-state-message")).toHaveText("Map service unavailable"); await expect(errorCard.getByRole("group", { name: "Map load details" })).toHaveCount(0); await expect(errorCard.locator(".location-map-setup-row", { hasText: "Error" })).toHaveCount(0); await expect(errorCard.locator(".location-map-setup-row", { hasText: "Status" })).toHaveCount(0); await expect(page.getByText("Load Error")).toBeVisible(); await expect(page.getByRole("heading", { name: "No Map" })).toHaveCount(0); await expect(page.getByRole("button", { name: /Create From/ })).toHaveCount(0); await page.getByRole("button", { name: "Retry" }).click(); await expect(page.getByRole("heading", { name: "Map Unavailable" })).toHaveCount(0); await expect(page.getByText("Published")).toBeVisible(); await expect(page.getByRole("button", { name: "Map area Bakery" })).toBeVisible(); expect(attempts).toBe(2); }); test("loading map keeps location context visible", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 461, 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); let resolveMapRequestStarted: () => void = () => {}; let releaseMapResponse: () => void = () => {}; let resolveMapResponseSent: () => void = () => {}; const mapRequestStarted = new Promise((resolve) => { resolveMapRequestStarted = resolve; }); const mapResponseGate = new Promise((resolve) => { releaseMapResponse = resolve; }); const mapResponseSent = new Promise((resolve) => { resolveMapResponseSent = resolve; }); await page.route("**/households/1/locations/10/map", async (route) => { resolveMapRequestStarted(); await mapResponseGate; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); resolveMapResponseSent(); }); const navigation = page.goto("/stores/100/locations/10/map"); await mapRequestStarted; await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible(); await expect(page.getByText("Eastvale")).toBeVisible(); await expect(page.getByRole("button", { name: "Back to grocery list" })).toBeVisible(); await expect(page.locator(".location-map-status")).toHaveText("Loading"); await expect(page.getByRole("status")).toHaveAccessibleName("Map status: Loading"); const messageCard = page.locator(".location-map-message-card"); await expect(messageCard.locator(".location-map-state-message")).toHaveText("Loading map..."); await expect(messageCard.getByRole("group", { name: "Map message details" })).toHaveCount(0); await expect(messageCard.locator(".location-map-setup-row", { hasText: "Message" })).toHaveCount(0); await expect(messageCard.locator(".location-map-setup-row", { hasText: "Status" })).toHaveCount(0); releaseMapResponse(); await mapResponseSent; await navigation; await expect(page.locator(".location-map-status")).toHaveText("Published"); await expect(page.getByRole("button", { name: "Map area Bakery" })).toBeVisible(); }); test("ignores stale map responses after switching locations", async ({ page }) => { await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]); const oldLocationState = publishedMapState([ { id: 471, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Old Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextLocationBaseState = publishedMapState([ { id: 472, location_map_id: 902, zone_id: 502, zone_name: "Produce", type: "zone", label: "Ontario Produce", x: 80, y: 80, width: 280, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextPublishedMap = { ...nextLocationBaseState.published_map, id: 902, store_location_id: secondStoreLocation.id, }; const nextLocationState = { ...nextLocationBaseState, location: secondStoreLocation, map: nextPublishedMap, published_map: nextPublishedMap, }; let resolveFirstRequestStarted: () => void = () => {}; let releaseFirstResponse: () => void = () => {}; let resolveFirstResponseSent: () => void = () => {}; const firstRequestStarted = new Promise((resolve) => { resolveFirstRequestStarted = resolve; }); const firstResponseGate = new Promise((resolve) => { releaseFirstResponse = resolve; }); const firstResponseSent = new Promise((resolve) => { resolveFirstResponseSent = resolve; }); await page.route("**/households/1/locations/10/map", async (route) => { resolveFirstRequestStarted(); await firstResponseGate; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(oldLocationState), }); resolveFirstResponseSent(); }); await page.route("**/households/1/locations/11/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(nextLocationState), }); }); await page.goto("/stores/100/locations/10/map"); await firstRequestStarted; await page.evaluate(() => { window.history.pushState({}, "", "/stores/100/locations/11/map"); window.dispatchEvent(new PopStateEvent("popstate")); }); await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible(); await expect(page.locator(".location-map-title span")).toHaveText("Ontario"); releaseFirstResponse(); await firstResponseSent; await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible(); await expect(page.getByRole("button", { name: "Map area Old Bakery" })).toHaveCount(0); await expect(page.locator(".location-map-title span")).toHaveText("Ontario"); }); test("ignores stale map save responses after switching locations", async ({ page }) => { await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]); const firstLocationState = draftMapState([ { id: 483, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Eastvale Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextLocationBaseState = publishedMapState([ { id: 484, location_map_id: 902, zone_id: 502, zone_name: "Produce", type: "zone", label: "Ontario Produce", x: 80, y: 80, width: 280, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextPublishedMap = { ...nextLocationBaseState.published_map, id: 902, store_location_id: secondStoreLocation.id, }; const nextLocationState = { ...nextLocationBaseState, location: secondStoreLocation, map: nextPublishedMap, published_map: nextPublishedMap, }; let releaseSaveResponse: () => void = () => {}; let resolveSaveStarted: () => void = () => {}; let resolveSaveResponded: () => void = () => {}; const saveStarted = new Promise((resolve) => { resolveSaveStarted = resolve; }); const saveGate = new Promise((resolve) => { releaseSaveResponse = resolve; }); const saveResponded = new Promise((resolve) => { resolveSaveResponded = resolve; }); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(firstLocationState), }); }); await page.route("**/households/1/locations/10/map/draft", async (route) => { resolveSaveStarted(); await saveGate; const payload = await route.request().postDataJSON(); const savedObjects = (payload.objects as Array>).map((object, index) => ({ id: 1480 + index, location_map_id: 900, zone_name: object.zone_id === 501 ? "Bakery" : null, ...object, label: "Saved Eastvale Bakery", })); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(draftMapState(savedObjects, true)), }); resolveSaveResponded(); }); await page.route("**/households/1/locations/11/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(nextLocationState), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Continue Editing" }).click(); await page.getByRole("button", { name: "Map area Eastvale Bakery" }).click(); await page.getByRole("textbox", { name: "Label" }).fill("Saving Eastvale Bakery"); await page.getByRole("button", { name: "Save Draft" }).click(); await saveStarted; await page.evaluate(() => { window.history.pushState({}, "", "/stores/100/locations/11/map"); window.dispatchEvent(new PopStateEvent("popstate")); }); await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible(); await expect(page.locator(".location-map-title span")).toHaveText("Ontario"); await expect(page.getByRole("button", { name: "Edit Draft" })).toBeEnabled(); releaseSaveResponse(); await saveResponded; await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible(); await expect(page.locator(".location-map-title span")).toHaveText("Ontario"); await expect(page.getByRole("button", { name: "Edit Draft" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Map area Saved Eastvale Bakery" })).toHaveCount(0); await expect(page.getByText("Saved draft")).toHaveCount(0); }); test("resets map layer filters after switching locations", async ({ page }) => { await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]); const firstLocationState = publishedMapState([ { id: 481, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Eastvale Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextLocationBaseState = publishedMapState([ { id: 482, location_map_id: 902, zone_id: 502, zone_name: "Produce", type: "zone", label: "Ontario Produce", x: 80, y: 80, width: 280, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextPublishedMap = { ...nextLocationBaseState.published_map, id: 902, store_location_id: secondStoreLocation.id, }; const nextLocationState = { ...nextLocationBaseState, location: secondStoreLocation, map: nextPublishedMap, published_map: nextPublishedMap, }; await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(firstLocationState), }); }); await page.route("**/households/1/locations/11/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(nextLocationState), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.getByRole("button", { name: "Map area Eastvale Bakery" })).toBeVisible(); await page.getByRole("button", { name: "Layers" }).click(); await page.getByRole("button", { name: "Zones" }).click(); await expect(page.getByText("Zones hidden in Layers")).toBeVisible(); await expect(page.getByRole("button", { name: "Map area Eastvale Bakery" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toBeVisible(); await page.goto("/stores/100/locations/11/map"); await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible(); await expect(page.getByText("Zones hidden in Layers")).toHaveCount(0); await expect(page.getByRole("button", { name: "Layers" })).toBeVisible(); await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveCount(0); }); test("closes stale map buy modal after switching locations", async ({ page }) => { await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]); const firstLocationState = publishedMapState([ { id: 491, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Eastvale Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextLocationBaseState = publishedMapState([ { id: 492, location_map_id: 902, zone_id: 502, zone_name: "Produce", type: "zone", label: "Ontario Produce", x: 80, y: 80, width: 280, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextPublishedMap = { ...nextLocationBaseState.published_map, id: 902, store_location_id: secondStoreLocation.id, }; const nextLocationState = { ...nextLocationBaseState, location: secondStoreLocation, map: nextPublishedMap, published_map: nextPublishedMap, items: [ { id: 1, item_name: "ontario oranges", quantity: 1, bought: false, zone_id: 502, zone: "Produce", added_by_users: ["map-user"], }, ], }; let resolveBuyRequestStarted: () => void = () => {}; let releaseBuyResponse: () => void = () => {}; let resolveBuyResponseSent: () => void = () => {}; const buyRequestStarted = new Promise((resolve) => { resolveBuyRequestStarted = resolve; }); const buyResponseGate = new Promise((resolve) => { releaseBuyResponse = resolve; }); const buyResponseSent = new Promise((resolve) => { resolveBuyResponseSent = resolve; }); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(firstLocationState), }); }); await page.route("**/households/1/locations/11/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(nextLocationState), }); }); await page.route("**/households/1/locations/10/list/item", async (route) => { if (route.request().method() !== "PATCH") { await route.fulfill({ status: 404, contentType: "application/json", body: "{}" }); return; } resolveBuyRequestStarted(); await buyResponseGate; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true }), }); resolveBuyResponseSent(); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Map area Eastvale Bakery" }).click(); await page.getByRole("button", { name: "Mark french bread bought" }).click(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("french bread"); await page.getByRole("button", { name: "Mark as Bought" }).click(); await buyRequestStarted; await page.evaluate(() => { window.history.pushState({}, "", "/stores/100/locations/11/map"); window.dispatchEvent(new PopStateEvent("popstate")); }); await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible(); await expect(page.locator(".location-map-title span")).toHaveText("Ontario"); await expect(page.locator(".confirm-buy-modal")).toHaveCount(0); await expect(page.getByText("french bread")).toHaveCount(0); await page.getByRole("button", { name: "Map area Ontario Produce" }).click(); await expect(page.getByRole("button", { name: "Mark ontario oranges bought" })).toBeVisible(); releaseBuyResponse(); await buyResponseSent; await expect(page.getByRole("button", { name: "Mark ontario oranges bought" })).toBeVisible(); await expect(page.getByText("Marked item bought")).toHaveCount(0); }); test("closes stale map delete confirmation after switching locations", async ({ page }) => { await mockMapShell(page, adminHousehold, [storeLocation, secondStoreLocation]); const firstLocationState = draftMapState([ { id: 493, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Eastvale Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextLocationBaseState = publishedMapState([ { id: 494, location_map_id: 902, zone_id: 502, zone_name: "Produce", type: "zone", label: "Ontario Produce", x: 80, y: 80, width: 280, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const nextPublishedMap = { ...nextLocationBaseState.published_map, id: 902, store_location_id: secondStoreLocation.id, }; const nextLocationState = { ...nextLocationBaseState, location: secondStoreLocation, map: nextPublishedMap, published_map: nextPublishedMap, }; await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(firstLocationState), }); }); await page.route("**/households/1/locations/11/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(nextLocationState), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Continue Editing" }).click(); await page.getByRole("button", { name: "Map area Eastvale Bakery" }).click(); await page.getByRole("button", { name: "Delete area Eastvale Bakery" }).click(); await expect(page.getByRole("heading", { name: "Delete Eastvale Bakery?" })).toBeVisible(); await page.evaluate(() => { window.history.pushState({}, "", "/stores/100/locations/11/map"); window.dispatchEvent(new PopStateEvent("popstate")); }); await expect(page.getByRole("button", { name: "Map area Ontario Produce" })).toBeVisible(); await expect(page.locator(".location-map-title span")).toHaveText("Ontario"); await expect(page.getByRole("heading", { name: "Delete Eastvale Bakery?" })).toHaveCount(0); await expect(page.locator(".confirm-slide-modal")).toHaveCount(0); }); test("admin can add the first area from a blank map canvas", async ({ page }) => { await mockMapShell(page); let mapState = { ...noMapState(true), zones: [], items: [], 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.route("**/households/1/locations/10/map/blank", async (route) => { mapState = { ...draftMapState([], true), zones: [], items: [], unmapped_count: 0, }; await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Create Blank Map" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByText("No map areas yet")).toBeVisible(); await expect( page.locator(".location-map-empty-canvas").getByRole("button", { name: "Add Area" }) ).toBeVisible(); await page.locator(".location-map-empty-canvas").getByRole("button", { name: "Add Area" }).click(); await expect(page.getByRole("button", { name: "Map area New Area" })).toBeVisible(); await expect(page.locator(".location-map-empty-canvas")).toHaveCount(0); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("New Area"); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("New Area"); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled(); }); test("admin added map areas stay inside the canvas", async ({ page }) => { await mockMapShell(page); const existingObjects = Array.from({ length: 31 }, (_, index) => ({ id: 5000 + index, location_map_id: 900, zone_id: null, zone_name: null, type: "zone", label: `Existing Area ${index + 1}`, x: 24 + (index % 5) * 128, y: 24 + Math.floor(index / 5) * 88, width: 120, height: 72, rotation: 0, locked: false, visible: true, sort_order: index + 1, })); const mapState = { ...draftMapState(existingObjects, true), zones: [], }; 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: "Add Area" }).click(); const newArea = page.getByRole("button", { name: "Map area New Area" }); await expect(newArea).toHaveAttribute("aria-pressed", "true"); await expect(newArea).toHaveAttribute("x", "780"); await expect(newArea).toHaveAttribute("y", "570"); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); }); test("admin view mode previews the edited draft 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: "View", exact: true }).click(); await expect(page.locator(".location-map-status")).toHaveText("Viewing Draft"); 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("Viewing Draft"); await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toBeVisible(); }); test("admin keeps the selected draft area when switching to view mode", async ({ page }) => { await mockMapShell(page); const publishedObjects = [ { id: 1211, 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: 1311, 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(); const draftBakeryArea = page.getByRole("button", { name: "Map area Draft Bakery" }); await draftBakeryArea.click(); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Draft Bakery"); await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1311"); await page.getByRole("button", { name: "View", exact: true }).click(); await expect(page.locator(".location-map-status")).toHaveText("Viewing Draft"); await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1311"); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Draft Bakery"); await expect(page.getByRole("button", { name: "Close selected map area" })).toBeVisible(); }); test("admin edit mode hides unavailable draft actions until changes exist", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { 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, }, ], 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: "Edit Draft" }).click(); const secondaryActions = page.locator(".location-map-secondary-actions"); await expect(page.locator(".location-map-primary-actions")).toHaveCount(0); await expect(secondaryActions.getByRole("button", { name: "Add Area" })).toBeVisible(); await expect(secondaryActions.getByRole("button", { name: "View Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Publish", exact: true })).toHaveCount(0); await secondaryActions.getByRole("button", { name: "Add Area" }).click(); 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: "Publish", exact: true })).toBeEnabled(); }); test("mode switches close open map layers without resetting filters", async ({ page }) => { await mockMapShell(page); const publishedObjects = [ { id: 1241, 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: 1242, 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: "Layers" }).click(); await page.getByRole("button", { name: "Pins" }).click(); await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "true"); await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false"); await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0); await page.getByRole("button", { name: "Layers, 1 changed" }).click(); await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible(); await page.getByRole("button", { name: "Edit Draft" }).click(); await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false"); await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0); await page.getByRole("button", { name: "Layers, 1 changed" }).click(); await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible(); await page.getByRole("button", { name: "View", exact: true }).click(); await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false"); await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0); await page.getByRole("button", { name: "Edit Draft" }).click(); await page.getByRole("button", { name: "Layers, 1 changed" }).click(); await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible(); await page.getByRole("button", { name: "View", exact: true }).click(); await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false"); await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0); }); test("mobile keeps viewing draft status compact in the topbar", async ({ page }) => { await page.setViewportSize({ width: 409, height: 838 }); 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: "View", exact: true }).click(); const topbar = page.locator(".location-map-topbar"); const backButton = page.getByRole("button", { name: "Back to grocery list" }); const status = page.locator(".location-map-status"); const toolbar = page.locator(".location-map-toolbar"); const modeGroup = page.locator(".location-map-mode-group"); const modeButtons = page.locator(".location-map-mode-buttons"); const historyActions = page.locator(".location-map-history-buttons"); const zoomControls = page.locator(".location-map-zoom-controls"); await expect(status).toHaveText("Viewing Draft"); const topbarBox = await topbar.boundingBox(); const backButtonBox = await backButton.boundingBox(); const titleBox = await page.locator(".location-map-title").boundingBox(); const statusBox = await status.boundingBox(); const toolbarBox = await toolbar.boundingBox(); const modeGroupBox = await modeGroup.boundingBox(); const modeBox = await modeButtons.boundingBox(); const zoomBox = await zoomControls.boundingBox(); expect(topbarBox).not.toBeNull(); expect(backButtonBox).not.toBeNull(); expect(titleBox).not.toBeNull(); expect(statusBox).not.toBeNull(); expect(toolbarBox).not.toBeNull(); expect(modeGroupBox).not.toBeNull(); expect(modeBox).not.toBeNull(); expect(zoomBox).not.toBeNull(); if ( !topbarBox || !backButtonBox || !titleBox || !statusBox || !toolbarBox || !modeGroupBox || !modeBox || !zoomBox ) { throw new Error("Mobile map controls layout was not measurable"); } expect(topbarBox.height).toBeLessThanOrEqual(56); expect(backButtonBox.width).toBeLessThanOrEqual(42); expect(titleBox.height).toBeLessThanOrEqual(24); expect(statusBox.height).toBeLessThanOrEqual(24); expect(statusBox.y).toBeLessThan(titleBox.y + titleBox.height); expect(statusBox.x).toBeGreaterThan(titleBox.x); expect(toolbarBox.height).toBeLessThanOrEqual(64); expect(modeBox.width).toBeGreaterThanOrEqual(130); expect(modeBox.width).toBeLessThanOrEqual(136); await expect(historyActions).toHaveCount(0); expect(zoomBox.y).toBeLessThan(modeBox.y + modeBox.height); expect(zoomBox.x).toBeGreaterThan(modeBox.x + modeBox.width - 1); expect(zoomBox.x + zoomBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 1); await page.getByRole("button", { name: "Edit Draft" }).click(); await expect(historyActions).toHaveCount(1); await expect(historyActions.getByRole("button", { name: "Undo" })).toBeVisible(); await expect(historyActions.getByRole("button", { name: "Redo" })).toBeVisible(); const editToolbarBox = await toolbar.boundingBox(); const editModeGroupBox = await modeGroup.boundingBox(); const editModeBox = await modeButtons.boundingBox(); const editHistoryBox = await historyActions.boundingBox(); const editZoomBox = await zoomControls.boundingBox(); expect(editToolbarBox).not.toBeNull(); expect(editModeGroupBox).not.toBeNull(); expect(editModeBox).not.toBeNull(); expect(editHistoryBox).not.toBeNull(); expect(editZoomBox).not.toBeNull(); if (!editToolbarBox || !editModeGroupBox || !editModeBox || !editHistoryBox || !editZoomBox) { throw new Error("Mobile edit controls layout was not measurable"); } expect(Math.abs(editModeBox.x - modeBox.x)).toBeLessThanOrEqual(1); expect(Math.abs(editModeGroupBox.width - modeGroupBox.width)).toBeLessThanOrEqual(1); expect(Math.abs(editModeBox.width - modeBox.width)).toBeLessThanOrEqual(1); expect(editToolbarBox.height).toBeLessThanOrEqual(64); expect(editModeBox.width).toBeGreaterThanOrEqual(130); expect(editModeBox.width).toBeLessThanOrEqual(136); expect(editHistoryBox.width).toBeGreaterThanOrEqual(82); expect(editHistoryBox.x).toBeGreaterThan(editModeBox.x + editModeBox.width); expect(editHistoryBox.x + editHistoryBox.width).toBeLessThanOrEqual(editToolbarBox.x + editToolbarBox.width + 1); expect(editZoomBox.y).toBeLessThan(editModeBox.y + editModeBox.height); expect(editZoomBox.x).toBeGreaterThan(editHistoryBox.x + editHistoryBox.width - 1); expect(editZoomBox.x + editZoomBox.width).toBeLessThanOrEqual(editToolbarBox.x + editToolbarBox.width + 1); }); test("mobile map refits after compact viewport resize until manually zoomed", async ({ page }) => { await page.setViewportSize({ width: 700, height: 844 }); await mockMapShell(page); const mapState = publishedMapState([ { id: 1258, 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.poll(async () => readMapZoomPercent(page)).toBeGreaterThan(0); const initialZoom = await readMapZoomPercent(page); await page.setViewportSize({ width: 390, height: 844 }); await expect.poll(async () => readMapZoomPercent(page)).toBeLessThan(initialZoom - 8); const refitZoom = await readMapZoomPercent(page); await page.getByRole("button", { name: "Zoom in" }).click(); await page.getByRole("button", { name: "Zoom in" }).click(); const manualZoom = await readMapZoomPercent(page); expect(manualZoom).toBeGreaterThan(refitZoom); await page.setViewportSize({ width: 700, height: 844 }); await page.waitForTimeout(150); await expect.poll(async () => readMapZoomPercent(page)).toBe(manualZoom); }); test("narrow mobile toolbar keeps map controls tappable without overflow", async ({ page }) => { await page.setViewportSize({ width: 360, height: 844 }); await mockMapShell(page); const mapState = draftMapState([ { id: 1261, 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(); const toolbar = page.locator(".location-map-toolbar"); const modeButtons = page.locator(".location-map-mode-buttons"); const historyActions = page.locator(".location-map-history-buttons"); const zoomControls = page.locator(".location-map-zoom-controls"); const toolbarBox = await toolbar.boundingBox(); const modeBox = await modeButtons.boundingBox(); const historyBox = await historyActions.boundingBox(); const zoomBox = await zoomControls.boundingBox(); expect(toolbarBox).not.toBeNull(); expect(modeBox).not.toBeNull(); expect(historyBox).not.toBeNull(); expect(zoomBox).not.toBeNull(); if (!toolbarBox || !modeBox || !historyBox || !zoomBox) { throw new Error("Narrow mobile toolbar layout was not measurable"); } expect(toolbarBox.x).toBeGreaterThanOrEqual(0); expect(toolbarBox.x + toolbarBox.width).toBeLessThanOrEqual(361); expect(modeBox.width).toBeGreaterThanOrEqual(130); expect(modeBox.width).toBeLessThanOrEqual(136); expect(modeBox.x + modeBox.width).toBeLessThanOrEqual(historyBox.x); expect(zoomBox.y).toBeGreaterThan(modeBox.y + modeBox.height - 1); expect(zoomBox.x).toBeGreaterThanOrEqual(toolbarBox.x); expect(zoomBox.x + zoomBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 1); const toolbarButtons = toolbar.getByRole("button"); const buttonCount = await toolbarButtons.count(); expect(buttonCount).toBe(7); await expect(toolbar.getByRole("button", { name: "Undo" })).toBeVisible(); await expect(toolbar.getByRole("button", { name: "Redo" })).toBeVisible(); await expect(toolbar.getByRole("button", { name: "Zoom out" })).toBeVisible(); await expect(toolbar.getByRole("button", { name: "Zoom in" })).toBeVisible(); await expect(toolbar.getByRole("button", { name: "Fit" })).toBeVisible(); for (let index = 0; index < buttonCount; index += 1) { const buttonBox = await toolbarButtons.nth(index).boundingBox(); expect(buttonBox).not.toBeNull(); if (!buttonBox) throw new Error("Toolbar button was not measurable"); expect(buttonBox.width).toBeGreaterThanOrEqual(40); expect(buttonBox.height).toBeGreaterThanOrEqual(40); } const iconButtons = toolbar.locator(".location-map-icon-button"); await expect(iconButtons).toHaveCount(5); for (let index = 0; index < await iconButtons.count(); index += 1) { const iconButtonBox = await iconButtons.nth(index).boundingBox(); expect(iconButtonBox).not.toBeNull(); if (!iconButtonBox) throw new Error("Toolbar icon button was not measurable"); expect(iconButtonBox.width).toBeLessThanOrEqual(44); expect(iconButtonBox.height).toBeLessThanOrEqual(44); await expect(iconButtons.nth(index).locator(".location-map-toolbar-icon")).toHaveCount(1); } }); 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(); const summary = page.getByRole("group", { name: "Draft map summary" }); await expect(summary.locator(".location-map-overview-row")).toHaveCount(1); await expect(summary.locator(".location-map-overview-row", { hasText: "Areas" }).locator("strong")).toHaveText("1"); await expect(summary.locator(".location-map-overview-row", { hasText: "Hidden" })).toHaveCount(0); await expect(summary.locator(".location-map-overview-row", { hasText: "Unlinked" })).toHaveCount(0); 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" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled(); await page.getByRole("textbox", { name: "Label" }).fill("Bakery"); await page.getByLabel("Linked zone").selectOption("501"); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); 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 undoing the only draft change clears unsaved state", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1356, 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: "Map area Bakery" }).click(); await page.getByLabel("Linked zone").selectOption(""); 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.getByRole("button", { name: "Undo" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(page.getByLabel("Linked zone")).toHaveValue("501"); await expect(page.getByRole("button", { name: "Redo" })).toBeEnabled(); await page.getByRole("button", { name: "Redo" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled(); await expect(page.getByLabel("Linked zone")).toHaveValue(""); await page.getByRole("button", { name: "Undo" }).focus(); await page.keyboard.down("Control"); await page.keyboard.press("KeyZ"); await page.keyboard.up("Control"); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(page.getByLabel("Linked zone")).toHaveValue("501"); await expect(page.getByRole("button", { name: "Redo" })).toBeEnabled(); await page.getByRole("button", { name: "Redo" }).focus(); await page.keyboard.down("Control"); await page.keyboard.press("KeyY"); await page.keyboard.up("Control"); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled(); await expect(page.getByLabel("Linked zone")).toHaveValue(""); }); test("admin can recover hidden map areas from the empty canvas", async ({ page }) => { await mockMapShell(page); let savedPayload: Record | null = null; let mapState = draftMapState([ { id: 1354, 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.route("**/households/1/locations/10/map/draft", async (route) => { savedPayload = await route.request().postDataJSON(); const savedObjects = (savedPayload.objects as Array>).map((object, index) => ({ id: 1370 + index, location_map_id: 900, zone_name: object.zone_id === 501 ? "Bakery" : null, ...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: "Map area Bakery" }).click(); const visibleToggle = page.getByRole("button", { name: "Visible" }); const lockedToggle = page.getByRole("button", { name: "Locked" }); await expect(visibleToggle).toHaveAttribute("aria-pressed", "true"); await expect(lockedToggle).toHaveAttribute("aria-pressed", "false"); await visibleToggle.click(); await expect(page.getByRole("button", { name: "Map area Bakery" })).toHaveCount(0); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Edit Areas"); await expect(page.getByText("No visible map areas")).toBeVisible(); await expect(page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" })).toBeVisible(); await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toHaveCount(0); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" }).click(); await expect(page.getByRole("button", { name: "Map area Bakery" })).toBeVisible(); await expect(page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toHaveCount(0); await page.getByRole("button", { name: "Save Draft" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); expect(savedPayload).not.toBeNull(); expect((savedPayload?.objects as Array>)[0]).toEqual( expect.objectContaining({ visible: true, zone_id: 501 }) ); }); test("admin edit mode summarizes draft areas before selection", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1381, 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, }, { id: 1382, location_map_id: 900, zone_id: null, zone_name: null, type: "zone", label: "Seasonal Endcap", x: 330, y: 40, width: 220, height: 130, rotation: 0, locked: false, visible: false, 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"); await page.getByRole("button", { name: "Continue Editing" }).click(); const summary = page.getByRole("group", { name: "Draft map summary" }); const areasRow = summary.locator(".location-map-overview-row", { hasText: "Areas" }); const hiddenRow = summary.locator(".location-map-overview-row", { hasText: "Hidden" }); const unlinkedRow = summary.locator(".location-map-overview-row", { hasText: "Unlinked" }); await expect(summary).toBeVisible(); await expect(areasRow.locator("strong")).toHaveText("2"); await expect(hiddenRow.locator("strong")).toHaveText("1"); await expect(unlinkedRow.locator("strong")).toHaveText("1"); await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toBeVisible(); await page.getByRole("button", { name: "Map area Bakery" }).click(); await expect(page.getByRole("group", { name: "Draft map summary" })).toHaveCount(0); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery"); }); test("admin locked map areas hide resize affordances", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1355, 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(); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }); await bakeryArea.click(); await expect(page.locator(".location-map-resize-handle")).toBeVisible(); await expect(page.locator(".location-map-lock-badge")).toHaveCount(0); await expect(page.getByRole("button", { name: "Map area Bakery, locked", exact: true })).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "X" })).toHaveCount(0); const lockedToggle = page.getByRole("button", { name: "Locked", exact: true }); const visibleToggle = page.getByRole("button", { name: "Visible", exact: true }); await expect(lockedToggle).toHaveAttribute("aria-pressed", "false"); await lockedToggle.click(); await expect(lockedToggle).toHaveAttribute("aria-pressed", "true"); await expect(page.getByRole("textbox", { name: "Label" })).toBeEnabled(); await expect(visibleToggle).toBeEnabled(); await expect(page.getByRole("spinbutton", { name: "X" })).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "Y" })).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "W" })).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "H" })).toHaveCount(0); await expect(page.locator(".location-map-resize-handle")).toHaveCount(0); await expect(page.locator(".location-map-lock-badge")).toBeVisible(); await expect(bakeryObject).toHaveClass(/is-locked/); await expect(page.getByRole("button", { name: "Map area Bakery, locked", exact: true })).toBeVisible(); await expect(page.getByText("Unlock this area before moving or resizing it.")).toHaveCount(0); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await bakeryArea.focus(); await page.keyboard.press("ArrowRight"); await expect(bakeryArea).toHaveAttribute("x", "40"); await lockedToggle.click(); await expect(lockedToggle).toHaveAttribute("aria-pressed", "false"); await expect(page.locator(".location-map-resize-handle")).toBeVisible(); await expect(page.locator(".location-map-lock-badge")).toHaveCount(0); await expect(page.getByRole("button", { name: "Map area Bakery, locked", exact: true })).toHaveCount(0); }); test("admin can drag map areas beyond the current right edge", async ({ page }) => { await page.setViewportSize({ width: 398, height: 617 }); await mockMapShell(page); const baseState = draftMapState([ { id: 1321, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 420, y: 80, width: 220, height: 140, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); const mapState = { ...baseState, map: { ...baseState.map, width: 700, height: 420 }, draft_map: { ...baseState.draft_map, width: 700, height: 420 }, }; let savedPayload: { map?: { width?: number; height?: number }; objects?: Array> } | 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/draft", async (route) => { savedPayload = await route.request().postDataJSON(); const savedState = draftMapState(savedPayload?.objects || [], true); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...savedState, map: { ...savedState.map, width: savedPayload?.map?.width, height: savedPayload?.map?.height }, draft_map: { ...savedState.draft_map, width: savedPayload?.map?.width, height: savedPayload?.map?.height }, }), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Continue Editing" }).click(); const scroll = page.locator(".location-map-scroll"); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); await bakeryArea.click(); const areaBox = await bakeryArea.boundingBox(); expect(areaBox).not.toBeNull(); if (!areaBox) throw new Error("Bakery map area was not measurable"); await page.mouse.move(areaBox.x + areaBox.width / 2, areaBox.y + areaBox.height / 2); await page.mouse.down(); await page.mouse.move(areaBox.x + areaBox.width + 180, areaBox.y + areaBox.height / 2, { steps: 12 }); await page.mouse.up(); await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0); await page.getByRole("button", { name: "Save Draft" }).click(); await expect.poll(() => savedPayload?.map?.width || 0).toBeGreaterThan(700); expect(savedPayload?.objects?.[0]).toEqual( expect.objectContaining({ label: "Bakery", x: expect.any(Number), }) ); expect(Number(savedPayload?.objects?.[0]?.x)).toBeGreaterThan(420); }); test("admin selected overlapping map area renders above other areas", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1311, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 40, y: 40, width: 260, height: 180, rotation: 0, locked: false, visible: true, sort_order: 1, }, { id: 1312, location_map_id: 900, zone_id: 502, zone_name: "Frozen Foods", type: "zone", label: "Frozen Foods", x: 120, y: 90, width: 260, height: 180, 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"); await page.getByRole("button", { name: "Continue Editing" }).click(); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); await bakeryArea.focus(); await page.keyboard.press("Enter"); await expect(bakeryArea).toHaveAttribute("aria-pressed", "true"); await expect.poll(async () => page.locator(".location-map-object").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-object-key")) ) ).toEqual(["1312", "1311"]); await expect(page.locator('[data-object-key="1311"] .location-map-resize-handle')).toBeVisible(); }); test("admin cleared map area labels keep linked zone action names", async ({ page }) => { await mockMapShell(page); let savedPayload: Record | null = null; let mapState = draftMapState([ { id: 1353, 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.route("**/households/1/locations/10/map/draft", async (route) => { savedPayload = await route.request().postDataJSON(); const savedObjects = (savedPayload.objects as Array>).map((object, index) => ({ id: 1360 + index, location_map_id: 900, zone_name: object.zone_id === 501 ? "Bakery" : null, ...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: "Map area Bakery" }).click(); await page.getByRole("textbox", { name: "Label" }).fill(""); await expect(page.getByRole("button", { name: "Map area Bakery", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Map area 1", exact: true })).toHaveCount(0); await page.getByRole("button", { name: "Save Draft" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); expect(savedPayload).not.toBeNull(); expect((savedPayload?.objects as Array>)[0]).toEqual( expect.objectContaining({ label: "", zone_id: 501 }) ); await expect(page.getByRole("button", { name: "Map area Bakery", exact: true })).toBeVisible(); await page.getByRole("button", { name: "Map area Bakery", exact: true }).click(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue(""); await page.getByRole("button", { name: "Delete area Bakery" }).click(); await expect(page.getByRole("heading", { name: "Delete Bakery?" })).toBeVisible(); await page.getByRole("button", { name: "Cancel" }).click(); await page.getByRole("button", { name: "Duplicate area Bakery" }).click(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery Copy"); await expect(page.getByRole("button", { name: "Map area Bakery Copy" })).toBeVisible(); await expect(page.getByRole("button", { name: "Map area Area Copy" })).toHaveCount(0); }); test("admin restores selected map area through duplicate undo and redo", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1355, 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: "Map area Bakery" }).click(); await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1355"); await page.getByRole("button", { name: "Duplicate area Bakery" }).click(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery Copy"); await expect(page.locator(".location-map-object.is-selected")).toContainText("Bakery Copy"); await page.getByRole("button", { name: "Undo" }).click(); await expect(page.getByRole("button", { name: "Map area Bakery Copy" })).toHaveCount(0); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery"); await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1355"); await page.getByRole("button", { name: "Redo" }).click(); await expect(page.getByRole("button", { name: "Map area Bakery Copy" })).toBeVisible(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery Copy"); await expect(page.locator(".location-map-object.is-selected")).toContainText("Bakery Copy"); }); 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(); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); await bakeryArea.focus(); await expect(bakeryArea).toBeFocused(); await page.keyboard.press("Enter"); await expect(page.getByText("Move, resize, link, rename, or nudge with arrow keys.")).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "X" })).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "Y" })).toHaveCount(0); await expect(bakeryArea).toHaveAttribute("x", "40"); await expect(bakeryArea).toHaveAttribute("y", "40"); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await page.keyboard.press("ArrowRight"); await expect(bakeryArea).toHaveAttribute("x", "50"); await expect(bakeryArea).toHaveAttribute("y", "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(bakeryArea).toHaveAttribute("x", "50"); await expect(bakeryArea).toHaveAttribute("y", "65"); }); test("admin cannot nudge map areas outside the canvas", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1353, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 740, y: 540, 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(); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); await bakeryArea.focus(); await page.keyboard.press("Enter"); await expect(bakeryArea).toHaveAttribute("x", "740"); await expect(bakeryArea).toHaveAttribute("y", "540"); await page.keyboard.press("Shift+ArrowRight"); await page.keyboard.press("Shift+ArrowDown"); await expect(bakeryArea).toHaveAttribute("x", "740"); await expect(bakeryArea).toHaveAttribute("y", "540"); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled(); }); test("admin cannot resize map areas beyond the canvas", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1354, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 820, y: 580, width: 180, 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"); await page.getByRole("button", { name: "Continue Editing" }).click(); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); await bakeryArea.click(); await expect(bakeryArea).toHaveAttribute("x", "820"); await expect(bakeryArea).toHaveAttribute("y", "580"); await expect(bakeryArea).toHaveAttribute("width", "180"); await expect(bakeryArea).toHaveAttribute("height", "120"); 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 + 160, resizeBox.y + resizeBox.height / 2 + 160, { steps: 8, }); await page.mouse.up(); await expect(bakeryArea).toHaveAttribute("x", "820"); await expect(bakeryArea).toHaveAttribute("y", "580"); await expect(bakeryArea).toHaveAttribute("width", "180"); await expect(bakeryArea).toHaveAttribute("height", "120"); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled(); }); test("admin selected map area uses compact editable fields", 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.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await expect(page.locator(".location-map-field-row")).toHaveCount(2); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bakery"); await expect(page.getByLabel("Linked zone")).toHaveValue("501"); await expect(page.getByLabel("Object type")).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "X" })).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "Y" })).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "W" })).toHaveCount(0); await expect(page.getByRole("spinbutton", { name: "H" })).toHaveCount(0); await page.getByRole("textbox", { name: "Label" }).focus(); await page.keyboard.press("Delete"); await expect(page.getByRole("heading", { name: "Delete Bakery?" })).toHaveCount(0); await expect(page.locator(".location-map-object.is-selected")).toHaveCount(1); await page.getByRole("button", { name: "Visible" }).focus(); await page.keyboard.press("Delete"); await expect(page.getByRole("heading", { name: "Delete Bakery?" })).toBeVisible(); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page.getByRole("heading", { name: "Delete Bakery?" })).toHaveCount(0); await expect(page.locator(".location-map-object.is-selected")).toHaveCount(1); await page.getByRole("textbox", { name: "Label" }).focus(); await page.keyboard.press("Escape"); await expect(page.getByRole("textbox", { name: "Label" })).toBeVisible(); await expect(page.locator(".location-map-object.is-selected")).toHaveCount(1); await page.getByRole("button", { name: "Visible" }).focus(); await page.keyboard.press("Escape"); await expect(page.getByRole("textbox", { name: "Label" })).toHaveCount(0); await expect(page.locator(".location-map-object.is-selected")).toHaveCount(0); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Edit Areas"); }); test("admin save shortcut 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((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>).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.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); const labelInput = page.getByRole("textbox", { name: "Label" }); await labelInput.fill("Saving Bakery"); await labelInput.focus(); await page.keyboard.down("Control"); await page.keyboard.press("KeyS"); await page.keyboard.up("Control"); 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: "View Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Duplicate area Saving Bakery" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Delete area Saving Bakery" })).toBeDisabled(); await expect(labelInput).toBeDisabled(); releaseSave(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(labelInput).toBeEnabled(); await expect(labelInput).toHaveValue("Saving Bakery"); await expect(page.getByRole("button", { name: "Map area Saving Bakery" })).toHaveAttribute("aria-pressed", "true"); }); test("admin save progress locks empty-canvas recovery actions", async ({ page }) => { await mockMapShell(page); let mapState = draftMapState([ { id: 1362, 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((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>).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.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await page.getByRole("button", { name: "Visible" }).click(); await expect(page.getByText("No visible map areas")).toBeVisible(); await expect( page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" }) ).toBeEnabled(); await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toHaveCount(0); await page.getByRole("button", { name: "Save Draft" }).click(); await expect.poll(() => saveStarted).toBe(true); await expect( page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" }) ).toBeDisabled(); await expect(page.getByRole("button", { name: "Show Hidden Areas (1)" })).toHaveCount(0); releaseSave(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect( page.locator(".location-map-empty-canvas").getByRole("button", { name: "Show Hidden" }) ).toBeEnabled(); }); 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.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 app navigation warns when draft edits are unsaved", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1372, 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.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Navbar Bakery"); await page.getByRole("link", { name: "Home" }).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.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Navbar Bakery"); await page.getByRole("link", { name: "Home" }).click(); await confirmSlide(page); await expect(page).toHaveURL(/\/$/); }); test("admin browser back warns when draft edits are unsaved", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1375, 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.evaluate(() => { window.history.replaceState(window.history.state, "", "/"); window.history.pushState({}, "", "/stores/100/locations/10/map"); }); await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/); await page.getByRole("button", { name: "Continue Editing" }).click(); await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Back Bakery"); await page.evaluate(() => window.history.back()); await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible(); await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Back Bakery"); await page.evaluate(() => window.history.back()); await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible(); await confirmSlide(page); await expect(page).toHaveURL(/\/$/); }); test("admin browser back leaves after draft edits are undone clean", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1376, 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.evaluate(() => { window.history.replaceState(window.history.state, "", "/"); window.history.pushState({}, "", "/stores/100/locations/10/map"); }); await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/); await page.getByRole("button", { name: "Continue Editing" }).click(); await page.getByRole("button", { name: "Map area Bakery" }).click(); await page.getByLabel("Linked zone").selectOption(""); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await page.getByRole("button", { name: "Undo" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toHaveCount(0); await page.evaluate(() => window.history.back()); await expect(page).toHaveURL(/\/$/); await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toHaveCount(0); }); test("admin logout warns when draft edits are unsaved", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1373, 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 logoutCalls = 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.route("**/auth/logout", async (route) => { logoutCalls += 1; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ message: "Logged out" }), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Continue Editing" }).click(); await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Logout Bakery"); await page.getByRole("button", { name: "User menu" }).click(); await page.getByRole("button", { name: "Logout", exact: true }).click(); await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible(); expect(logoutCalls).toBe(0); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Logout Bakery"); expect(logoutCalls).toBe(0); await page.getByRole("button", { name: "Logout", exact: true }).click(); await confirmSlide(page); await expect(page).toHaveURL(/\/login$/); expect(logoutCalls).toBe(1); }); test("admin household switch warns when draft edits are unsaved", async ({ page }) => { const secondHousehold = { id: 2, name: "Second House", role: "admin", invite_code: "EFGH5678", }; await mockMapShell(page, [adminHousehold, secondHousehold]); const mapState = draftMapState([ { id: 1374, 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); const secondHouseholdMapState = { ...noMapState(true), location: { ...storeLocation, household_id: 2, name: "Second Costco", display_name: "Second Costco - Eastvale", }, }; let secondHouseholdMapRequests = 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.route("**/households/2/stores", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([{ ...storeLocation, household_id: 2 }]), }); }); await page.route("**/households/2/locations/10/zones", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ zones }), }); }); await page.route("**/households/2/locations/10/map", async (route) => { secondHouseholdMapRequests += 1; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(secondHouseholdMapState), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Continue Editing" }).click(); await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await page.getByRole("textbox", { name: "Label" }).fill("Unsaved Household Bakery"); await page.locator(".household-switcher-toggle").click(); await page.getByRole("button", { name: "Second House", exact: true }).click(); await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible(); expect(secondHouseholdMapRequests).toBe(0); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page.locator(".household-switcher-toggle .household-name")).toHaveText("Map House"); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Unsaved Household Bakery"); expect(secondHouseholdMapRequests).toBe(0); await page.getByRole("button", { name: "Second House", exact: true }).click(); await confirmSlide(page); await expect(page.locator(".household-switcher-toggle .household-name")).toHaveText("Second House"); await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible(); expect(secondHouseholdMapRequests).toBe(1); }); 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 | 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>).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 Draft" }).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); await expect(page.locator(".location-map-object.is-selected")).toHaveAttribute("data-object-key", "1450"); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Quick Publish Bakery"); await expect(page.getByRole("button", { name: "Close selected map area" })).toBeVisible(); }); test("admin publish failure keeps successfully saved pending edits", async ({ page }) => { await mockMapShell(page); let mapState = publishedMapState([ { id: 1461, 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 | 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>).map((object, index) => ({ id: 1465 + 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; await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { message: "Publish service unavailable" } }), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Edit Draft" }).click(); await page.locator(".location-map-object", { hasText: "Live Bakery" }).locator("rect").first().click(); await page.getByRole("textbox", { name: "Label" }).fill("Saved Draft 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: "Saved Draft Bakery", zone_id: 501, }), ]) ); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Map area Saved Draft 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(); const layerFilters = page.getByRole("group", { name: "Layer filters" }); const labelsToggle = layerFilters.getByRole("button", { name: "Labels" }); await expect(layerFilters.locator(".location-map-layer-state")).toHaveCount(8); await expect(labelsToggle).toHaveAttribute("aria-pressed", "true"); await expect(labelsToggle.locator(".location-map-layer-state.is-on")).toHaveCount(1); await expect(page.getByRole("button", { name: "Bought" })).toBeVisible(); await expect(page.getByRole("button", { name: "Done" })).toHaveCount(0); await labelsToggle.click(); await expect(labelsToggle).toHaveAttribute("aria-pressed", "false"); await expect(labelsToggle.locator(".location-map-layer-state.is-on")).toHaveCount(0); 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: "Layers, 1 changed" }).click(); await expect(page.getByRole("button", { name: "Reset layer filters" })).toBeVisible(); await page.getByRole("button", { name: "Reset layer filters" }).click(); await expect(page.getByRole("button", { name: "Layers" })).toBeVisible(); await expect(page.getByRole("button", { name: "Reset layer filters" })).toHaveCount(0); 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: "Map area Bakery" }).click(); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery"); await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items"); await page.getByRole("button", { name: "Layers" }).click(); await page.getByRole("button", { name: "Zones" }).click(); await expect(page.getByRole("button", { name: "Map area Bakery" })).toHaveCount(0); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Map Details"); await expect(page.locator(".location-map-sheet-count")).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 map overview before selecting an area", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 1531, 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"); const overview = page.getByRole("group", { name: "Map summary" }); const areasRow = overview.locator(".location-map-overview-row", { hasText: "Areas" }); const mappedRow = overview.locator(".location-map-overview-row", { hasText: "Mapped items" }); const unmappedRow = overview.locator(".location-map-overview-row", { hasText: "Unmapped" }); await expect(overview).toBeVisible(); await expect(areasRow.locator("strong")).toHaveText("1 shown"); await expect(mappedRow.locator("strong")).toHaveText("2 shown"); await expect(unmappedRow.locator("strong")).toHaveText("1 hidden"); await page.getByRole("button", { name: "Layers" }).click(); await page.getByRole("button", { name: "Unmapped" }).click(); await expect(unmappedRow.locator("strong")).toHaveText("1"); await page.getByRole("button", { name: "Map area Bakery" }).click(); await expect(page.getByRole("group", { name: "Map summary" })).toHaveCount(0); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery"); }); test("viewer omits zero item rows from the map overview", async ({ page }) => { await mockMapShell(page); const mapState = { ...publishedMapState([ { id: 1531, 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), items: [], 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"); const overview = page.getByRole("group", { name: "Map summary" }); await expect(overview.locator(".location-map-overview-row")).toHaveCount(1); await expect(overview.locator(".location-map-overview-row", { hasText: "Areas" }).locator("strong")).toHaveText("1 shown"); await expect(overview.locator(".location-map-overview-row", { hasText: "Mapped items" })).toHaveCount(0); await expect(overview.locator(".location-map-overview-row", { hasText: "Unmapped" })).toHaveCount(0); }); 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(); const unmappedToggle = page.getByRole("group", { name: "Layer filters" }).getByRole("button", { name: "Unmapped" }); await expect(unmappedToggle).toHaveAttribute("aria-pressed", "false"); await unmappedToggle.click(); await expect(unmappedToggle).toHaveAttribute("aria-pressed", "true"); 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.getByRole("button", { name: "Show 3 more unmapped items" })).toHaveText("+3 more"); const firstUnmappedButtonBox = await page.getByRole("button", { name: "Mark unmapped item 1 bought" }).boundingBox(); const moreUnmappedButtonBox = await page.getByRole("button", { name: "Show 3 more unmapped items" }).boundingBox(); expect(firstUnmappedButtonBox).not.toBeNull(); expect(moreUnmappedButtonBox).not.toBeNull(); if (!firstUnmappedButtonBox || !moreUnmappedButtonBox) { throw new Error("Unmapped item controls were not measurable"); } expect(firstUnmappedButtonBox.height).toBeGreaterThanOrEqual(40); expect(moreUnmappedButtonBox.height).toBeGreaterThanOrEqual(40); await page.getByRole("button", { name: "Show 3 more unmapped items" }).click(); await expect(page.getByText("unmapped item 9")).toBeVisible(); await expect(page.getByText("unmapped item 11")).toBeVisible(); await expect(page.getByRole("button", { name: "Show fewer unmapped items" })).toHaveText("Show fewer"); await page.getByRole("button", { name: "Show fewer unmapped items" }).click(); await expect(page.getByText("unmapped item 9")).toHaveCount(0); }); test("viewer can buy unmapped map items in place", async ({ page }) => { await mockMapShell(page); const unmappedMapItems = [ { id: 4201, item_name: "loose batteries", quantity: 1, bought: false, zone_id: null, zone: null, added_by_users: ["map-user"], }, { id: 4202, item_name: "paper plates", quantity: 2, bought: false, zone_id: null, zone: null, added_by_users: ["map-user"], }, ]; const mapState = { ...publishedMapState([], true), items: unmappedMapItems, unmapped_count: unmappedMapItems.length, }; const boughtPayloads: Array> = []; 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/list/item", async (route) => { boughtPayloads.push(await route.request().postDataJSON()); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true }), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Layers" }).click(); await page.getByRole("button", { name: "Unmapped" }).click(); await page.getByRole("button", { name: "Mark loose batteries bought" }).click(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("loose batteries"); await page.getByRole("button", { name: "Mark as Bought" }).click(); await expect.poll(() => boughtPayloads.length).toBe(1); expect(boughtPayloads[0]).toMatchObject({ item_name: "loose batteries", bought: true, quantity_bought: 1, }); await expect(page.locator(".confirm-buy-item-name")).toHaveText("paper plates"); await expect(page.getByText("loose batteries")).toHaveCount(0); await expect(page.getByRole("group", { name: "Map summary" }).locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("1 shown"); await page.getByRole("button", { name: "Mark as Bought" }).click(); await expect.poll(() => boughtPayloads.length).toBe(2); expect(boughtPayloads[1]).toMatchObject({ item_name: "paper plates", bought: true, quantity_bought: 2, }); await expect(page.locator(".confirm-buy-modal")).toHaveCount(0); await expect(page.getByText("paper plates")).toHaveCount(0); await expect(page.getByRole("group", { name: "Map summary" }).locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("0 shown"); await page.getByRole("button", { name: "Bought" }).click(); await expect(page.getByRole("group", { name: "Map summary" }).locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("2"); const looseBatteriesRow = page.getByRole("button", { name: "loose batteries bought" }); const paperPlatesRow = page.getByRole("button", { name: "paper plates bought" }); await expect(looseBatteriesRow).toBeVisible(); await expect(looseBatteriesRow).toBeDisabled(); await expect(paperPlatesRow).toBeVisible(); await expect(paperPlatesRow).toBeDisabled(); }); test("viewer hides unrelated unmapped items while a zone is selected", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 1531, 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: "Layers" }).click(); await page.getByRole("button", { name: "Unmapped" }).click(); await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible(); await expect(page.getByRole("group", { name: "Unmapped items" })).toBeVisible(); await expect(page.getByText("Unmapped Items")).toHaveCount(0); await expect(page.getByText("loose batteries")).toBeVisible(); await page.locator('[data-object-key="1531"]').locator("rect").first().click(); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery"); await expect(page.getByRole("button", { name: "Layers, 1 changed" })).toHaveAttribute("aria-expanded", "false"); await expect(page.getByRole("group", { name: "Layer filters" })).toHaveCount(0); await expect(page.getByText("french bread")).toBeVisible(); await expect(page.getByRole("group", { name: "Unmapped items" })).toHaveCount(0); await expect(page.getByText("loose batteries")).toHaveCount(0); await page.locator(".location-map-background").click({ position: { x: 8, y: 8 } }); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Map Details"); await expect(page.getByRole("group", { name: "Unmapped items" })).toBeVisible(); await expect(page.getByText("Unmapped Items")).toHaveCount(0); await expect(page.getByText("loose batteries")).toBeVisible(); }); test("viewer can buy selected zone items from the map", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 1532, 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); const boughtPayloads: Array> = []; 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/list/item", async (route) => { if (route.request().method() !== "PATCH") { await route.fulfill({ status: 404, contentType: "application/json", body: "{}" }); return; } boughtPayloads.push(await route.request().postDataJSON()); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true }), }); }); await page.goto("/stores/100/locations/10/map"); const bakeryArea = page.locator('[data-object-key="1532"]'); await bakeryArea.locator("rect").first().click(); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery"); await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items"); await expect(bakeryArea.locator(".location-map-count")).toHaveText("2 items"); await page.getByRole("button", { name: "Mark french bread bought" }).click(); await expect(page.locator(".confirm-buy-modal")).toBeVisible(); await expect(page.getByRole("dialog", { name: "french bread" })).toBeVisible(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("french bread"); await expect(page.getByRole("img", { name: "No item photo" })).toHaveText("\uD83D\uDCE6"); const nextItemButton = page.getByRole("button", { name: "Next item" }); const nextItemButtonBox = await nextItemButton.boundingBox(); expect(nextItemButtonBox).not.toBeNull(); if (!nextItemButtonBox) throw new Error("Map buy next item button was not measurable"); expect(nextItemButtonBox.width).toBeGreaterThanOrEqual(40); expect(nextItemButtonBox.height).toBeGreaterThanOrEqual(40); await nextItemButton.click(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("sourdough"); await page.keyboard.press("ArrowLeft"); await expect(page.locator(".confirm-buy-item-name")).toHaveText("french bread"); await page.keyboard.press("ArrowRight"); await expect(page.locator(".confirm-buy-item-name")).toHaveText("sourdough"); const previousItemButton = page.getByRole("button", { name: "Previous item" }); const previousItemButtonBox = await previousItemButton.boundingBox(); expect(previousItemButtonBox).not.toBeNull(); if (!previousItemButtonBox) throw new Error("Map buy previous item button was not measurable"); expect(previousItemButtonBox.width).toBeGreaterThanOrEqual(40); expect(previousItemButtonBox.height).toBeGreaterThanOrEqual(40); await previousItemButton.click(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("french bread"); await page.getByRole("button", { name: "Mark as Bought" }).click(); await expect.poll(() => boughtPayloads.length).toBe(1); expect(boughtPayloads[0]).toMatchObject({ item_name: "french bread", bought: true, quantity_bought: 1, }); await expect(page.locator(".confirm-buy-item-name")).toHaveText("sourdough"); await expect(page.locator(".location-map-sheet-count")).toHaveText("1 shown"); await expect(bakeryArea.locator(".location-map-count")).toHaveText("1 shown"); await expect(page.getByRole("group", { name: "Zone items" })).not.toContainText("french bread"); await page.getByRole("button", { name: "Mark as Bought" }).click(); await expect.poll(() => boughtPayloads.length).toBe(2); expect(boughtPayloads[1]).toMatchObject({ item_name: "sourdough", bought: true, quantity_bought: 1, }); await expect(page.locator(".confirm-buy-modal")).toHaveCount(0); await expect(page.locator(".location-map-sheet-count")).toHaveText("0 shown"); await expect(bakeryArea.locator(".location-map-count")).toHaveText("0 shown"); const hiddenZoneState = page.getByRole("group", { name: "Zone items" }).locator(".location-map-inline-state", { hasText: "Hidden" }); await expect(hiddenZoneState).toContainText("2"); await expect(hiddenZoneState.getByRole("button", { name: "Show Zone Items" })).toHaveText("Show"); await expect(page.getByText("Items are assigned here, but hidden by layer filters.")).toHaveCount(0); await page.getByRole("button", { name: "Layers" }).click(); await page.getByRole("button", { name: "Bought" }).click(); await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items"); await expect(page.getByRole("button", { name: "french bread bought" })).toBeDisabled(); await expect(page.getByRole("button", { name: "sourdough bought" })).toBeDisabled(); }); test("viewer can close map buy modal with Escape", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 1533, 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); const boughtPayloads: Array> = []; 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/list/item", async (route) => { if (route.request().method() === "PATCH") { boughtPayloads.push(await route.request().postDataJSON()); } await route.fulfill({ status: 404, contentType: "application/json", body: "{}" }); }); await page.goto("/stores/100/locations/10/map"); await page.locator('[data-object-key="1533"]').locator("rect").first().click(); await page.getByRole("button", { name: "Mark french bread bought" }).click(); await expect(page.locator(".confirm-buy-modal")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.locator(".confirm-buy-modal")).toHaveCount(0); expect(boughtPayloads).toEqual([]); await expect(page.getByRole("button", { name: "Mark french bread bought" })).toBeVisible(); }); test("viewer advances map buy modal after partial quantity buys", async ({ page }) => { await mockMapShell(page); const partialItems = [ { id: 4301, item_name: "flour", quantity: 3, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["map-user"], }, { id: 4302, item_name: "yeast", quantity: 1, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["map-user"], }, ]; const updatedFlour = { ...partialItems[0], quantity: 2 }; const mapState = { ...publishedMapState([ { id: 1534, 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), items: partialItems, }; const boughtPayloads: Array> = []; 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/list/item**", async (route) => { if (route.request().method() === "GET") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(updatedFlour), }); return; } boughtPayloads.push(await route.request().postDataJSON()); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true }), }); }); await page.goto("/stores/100/locations/10/map"); await page.locator('[data-object-key="1534"]').locator("rect").first().click(); await page.getByRole("button", { name: "Mark flour bought" }).click(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("flour"); const quantityInput = page.getByRole("spinbutton", { name: "Quantity to mark bought" }); await page.keyboard.press("ArrowDown"); await page.keyboard.press("ArrowDown"); await expect(quantityInput).toHaveValue("1"); await page.keyboard.press("ArrowUp"); await expect(quantityInput).toHaveValue("2"); await page.keyboard.press("ArrowDown"); await expect(quantityInput).toHaveValue("1"); await page.getByRole("button", { name: "Mark as Bought" }).click(); await expect.poll(() => boughtPayloads.length).toBe(1); expect(boughtPayloads[0]).toMatchObject({ item_name: "flour", bought: true, quantity_bought: 1, }); await expect(page.locator(".confirm-buy-item-name")).toHaveText("yeast"); await expect(page.getByRole("button", { name: "Mark flour bought" }).locator("small")).toHaveText("x2"); }); test("viewer keeps map buy flow moving when quantity refresh fails", async ({ page }) => { await mockMapShell(page); const partialItems = [ { id: 4311, item_name: "flour", quantity: 3, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["map-user"], }, { id: 4312, item_name: "yeast", quantity: 1, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["map-user"], }, ]; const mapState = { ...publishedMapState([ { id: 1535, 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), items: partialItems, }; const boughtPayloads: Array> = []; 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/list/item**", async (route) => { if (route.request().method() === "GET") { await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "refresh unavailable" }), }); return; } boughtPayloads.push(await route.request().postDataJSON()); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true }), }); }); await page.goto("/stores/100/locations/10/map"); await page.locator('[data-object-key="1535"]').locator("rect").first().click(); await page.getByRole("button", { name: "Mark flour bought" }).click(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("flour"); await page.locator(".confirm-buy-counter-btn").first().click(); await page.locator(".confirm-buy-counter-btn").first().click(); await expect(page.locator(".confirm-buy-counter-display")).toHaveValue("1"); await page.getByRole("button", { name: "Mark as Bought" }).click(); await expect.poll(() => boughtPayloads.length).toBe(1); expect(boughtPayloads[0]).toMatchObject({ item_name: "flour", bought: true, quantity_bought: 1, }); await expect(page.getByText("Updated item locally")).toBeVisible(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("yeast"); await expect(page.getByRole("button", { name: "Mark flour bought" }).locator("small")).toHaveText("x2"); await expect(page.getByText("Mark item bought failed")).toHaveCount(0); }); test("viewer shows completed zone items without rebuy actions", async ({ page }) => { await mockMapShell(page); const mapState = { ...publishedMapState([ { id: 1533, 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), items: [ { ...items[0], bought: true, }, items[1], ], }; const boughtPayloads: Array> = []; 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/list/item", async (route) => { if (route.request().method() !== "PATCH") { await route.fulfill({ status: 404, contentType: "application/json", body: "{}" }); return; } boughtPayloads.push(await route.request().postDataJSON()); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true }), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Layers" }).click(); await page.getByRole("button", { name: "Bought" }).click(); await page.locator('[data-object-key="1533"]').locator("rect").first().click(); const boughtRow = page.getByRole("button", { name: "french bread bought" }); await expect(boughtRow).toBeVisible(); await expect(boughtRow).toBeDisabled(); await expect(boughtRow.locator("small")).toHaveText("Bought"); await boughtRow.click({ force: true }); await expect(page.locator(".confirm-buy-modal")).toHaveCount(0); expect(boughtPayloads).toHaveLength(0); await page.getByRole("button", { name: "Mark sourdough bought" }).click(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("sourdough"); await page.getByRole("button", { name: "Mark as Bought" }).click(); await expect.poll(() => boughtPayloads.length).toBe(1); expect(boughtPayloads[0]).toMatchObject({ item_name: "sourdough", bought: true, quantity_bought: 1, }); await expect(page.locator(".confirm-buy-modal")).toHaveCount(0); const newlyBoughtRow = page.getByRole("button", { name: "sourdough bought" }); await expect(newlyBoughtRow).toBeVisible(); await expect(newlyBoughtRow).toBeDisabled(); await expect(newlyBoughtRow.locator("small")).toHaveText("Bought"); }); test("viewer shows compact state when unmapped items are hidden by filters", async ({ page }) => { await mockMapShell(page); const mapState = { ...publishedMapState([], true), items: [ { id: 4100, item_name: "other batteries", quantity: 1, bought: false, zone_id: null, zone: null, added_by_users: ["other-user"], }, ], unmapped_count: 1, }; 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.getByRole("button", { name: "Unmapped" }).click(); await expect(page.getByText("other batteries")).toHaveCount(0); const hiddenUnmappedState = page.getByRole("group", { name: "Unmapped items" }).locator(".location-map-inline-state", { hasText: "Hidden" }); await expect(hiddenUnmappedState).toContainText("1"); await expect(hiddenUnmappedState.getByRole("button", { name: "Show Unmapped Items" })).toHaveText("Show"); await expect(page.getByText("Unmapped items are hidden by layer filters.")).toHaveCount(0); await page.getByRole("button", { name: "Show Unmapped Items" }).click(); await expect(page.getByText("other batteries")).toBeVisible(); await expect(hiddenUnmappedState).toHaveCount(0); await expect(page.getByRole("button", { name: "Layers, 3 changed" })).toBeVisible(); }); test("viewer marks partial unmapped counts when filters hide some items", async ({ page }) => { await mockMapShell(page); const mapState = { ...publishedMapState([], true), items: [ { id: 4110, item_name: "visible batteries", quantity: 1, bought: false, zone_id: null, zone: null, added_by_users: ["map-user"], }, { id: 4111, item_name: "hidden batteries", quantity: 1, bought: false, zone_id: null, zone: null, added_by_users: ["other-user"], }, ], unmapped_count: 2, }; 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.getByRole("button", { name: "Unmapped" }).click(); await expect(page.getByText("visible batteries")).toBeVisible(); await expect(page.getByText("hidden batteries")).toHaveCount(0); const hiddenUnmappedState = page.getByRole("group", { name: "Unmapped items" }).locator(".location-map-inline-state", { hasText: "Hidden" }); await expect(hiddenUnmappedState).toContainText("1"); await expect(hiddenUnmappedState.getByRole("button", { name: "Show All Unmapped Items" })).toHaveText("Show All"); await expect(page.getByText("1 more hidden by layer filters.")).toHaveCount(0); await page.getByRole("button", { name: "Show All Unmapped Items" }).click(); await expect(page.getByText("hidden batteries")).toBeVisible(); await expect(hiddenUnmappedState).toHaveCount(0); }); test("viewer shows compact state 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 shown"); 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"); const hiddenZoneState = page.getByRole("group", { name: "Zone items" }).locator(".location-map-inline-state", { hasText: "Hidden" }); await expect(hiddenZoneState).toContainText("1"); await expect(hiddenZoneState.getByRole("button", { name: "Show Zone Items" })).toHaveText("Show"); await expect(page.getByText("Items are assigned here, but hidden by layer filters.")).toHaveCount(0); 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 marks partial zone counts when filters hide some items", async ({ page }) => { await mockMapShell(page); const mapState = { ...publishedMapState([ { id: 1552, 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), items: [ { id: 5001, item_name: "visible bread", quantity: 1, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["map-user"], }, { id: 5002, item_name: "hidden baguette", quantity: 1, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["other-user"], }, ], }; 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.locator('[data-object-key="1552"]'); await expect(bakeryArea.locator(".location-map-count")).toHaveText("1 shown"); await bakeryArea.locator("rect").first().click(); await expect(page.locator(".location-map-sheet-count")).toHaveText("1 shown"); await expect(page.getByText("visible bread")).toBeVisible(); await expect(page.getByText("hidden baguette")).toHaveCount(0); const hiddenZoneState = page.getByRole("group", { name: "Zone items" }).locator(".location-map-inline-state", { hasText: "Hidden" }); await expect(hiddenZoneState).toContainText("1"); await expect(hiddenZoneState.getByRole("button", { name: "Show All Zone Items" })).toHaveText("Show All"); await expect(page.getByText("1 more hidden by layer filters.")).toHaveCount(0); await page.getByRole("button", { name: "Show All Zone Items" }).click(); await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items"); await expect(page.getByText("hidden baguette")).toBeVisible(); await expect(bakeryArea.locator(".location-map-count")).toHaveText("2 items"); }); 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.getByRole("group", { name: "Zone items" })).toHaveCount(0); await expect(page.locator(".location-map-inline-state", { hasText: "Items" })).toHaveCount(0); await expect(page.getByText("No items assigned to this zone yet.")).toHaveCount(0); await page.locator('[data-object-key="1561"]').locator("rect").first().click(); await expect(page.locator(".location-map-sheet-count")).toHaveText("14 items"); const zoneItems = page.getByRole("group", { name: "Zone items" }); await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(8); await expect(zoneItems.locator(".location-map-item-row").first()).toContainText("bulk item 1"); await expect(page.getByText("bulk item 9")).toHaveCount(0); await expect(page.getByRole("button", { name: "Show 6 more zone items" })).toHaveText("+6 more"); const firstZoneItemButtonBox = await page.getByRole("button", { name: "Mark bulk item 1 bought" }).boundingBox(); const moreZoneItemsButtonBox = await page.getByRole("button", { name: "Show 6 more zone items" }).boundingBox(); expect(firstZoneItemButtonBox).not.toBeNull(); expect(moreZoneItemsButtonBox).not.toBeNull(); if (!firstZoneItemButtonBox || !moreZoneItemsButtonBox) { throw new Error("Zone item controls were not measurable"); } expect(firstZoneItemButtonBox.height).toBeGreaterThanOrEqual(40); expect(moreZoneItemsButtonBox.height).toBeGreaterThanOrEqual(40); await page.getByRole("button", { name: "Show 6 more zone items" }).click(); await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(14); await expect(zoneItems.locator(".location-map-item-row").last()).toContainText("bulk item 14"); await expect(page.getByRole("button", { name: "Show fewer zone items" })).toHaveText("Show fewer"); await expect(page.locator(".location-map-pin")).toHaveCount(0); }); test("viewer collapses expanded zone items after selecting another zone", async ({ page }) => { await mockMapShell(page); const bakeryItems = Array.from({ length: 11 }, (_, index) => ({ id: 5100 + index, item_name: `bakery item ${index + 1}`, quantity: 1, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["map-user"], })); const frozenItems = Array.from({ length: 10 }, (_, index) => ({ id: 5200 + index, item_name: `frozen item ${index + 1}`, quantity: 1, bought: false, zone_id: 502, zone: "Frozen Foods", added_by_users: ["map-user"], })); const mapState = { ...publishedMapState([ { id: 1563, 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: 1564, 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: [...bakeryItems, ...frozenItems], 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 page.locator('[data-object-key="1563"]').locator("rect").first().click(); const zoneItems = page.getByRole("group", { name: "Zone items" }); await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(8); await page.getByRole("button", { name: "Show 3 more zone items" }).click(); await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(11); await expect(page.getByText("bakery item 11")).toBeVisible(); await page.locator('[data-object-key="1564"]').locator("rect").first().click(); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Frozen Foods"); await expect(zoneItems.locator(".location-map-item-row")).toHaveCount(8); await expect(page.getByText("frozen item 8")).toBeVisible(); await expect(page.getByText("frozen item 9")).toHaveCount(0); await expect(page.getByText("bakery item 11")).toHaveCount(0); await expect(page.getByRole("button", { name: "Show 2 more zone items" })).toHaveText("+2 more"); await expect(page.getByRole("button", { name: "Show fewer zone items" })).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" }); const bakeryRect = page.locator('[data-object-key="1571"]').locator("rect").first(); await expect(page.getByRole("button", { name: "Map area Bakery, 2 items", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Map area Frozen Foods, 0 shown", exact: true })).toBeVisible(); await expect(bakeryArea).toHaveAttribute("aria-pressed", "false"); const initialFill = await bakeryRect.evaluate((element) => getComputedStyle(element).fill); await bakeryRect.hover(); await expect.poll(async () => ( bakeryRect.evaluate((element) => getComputedStyle(element).fill) )).not.toBe(initialFill); 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"); await page.keyboard.press("Escape"); await expect(frozenArea).toBeFocused(); await expect(frozenArea).toHaveAttribute("aria-pressed", "false"); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Map Details"); await expect(page.getByRole("group", { name: "Map summary" })).toBeVisible(); }); test("mobile starts fitted and fit returns panned maps into the 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"); const { scroll } = await expectMapFitsCanvas(page, 45, "Initial mobile"); const zoomIn = page.getByRole("button", { name: "Zoom in" }); await zoomIn.click(); await zoomIn.click(); await zoomIn.click(); await scroll.evaluate((element) => { element.scrollLeft = 320; element.scrollTop = 240; }); const pannedScroll = await scroll.evaluate((element) => ({ left: element.scrollLeft, top: element.scrollTop, })); expect(pannedScroll.left).toBeGreaterThan(0); expect(pannedScroll.top).toBeGreaterThan(0); await page.getByRole("button", { name: "Fit" }).click(); await expectMapFitsCanvas(page, 45, "Mobile"); await expect.poll(async () => scroll.evaluate((element) => ({ left: element.scrollLeft, top: element.scrollTop, }))).toEqual({ left: 0, top: 0 }); }); test("compact tablet map starts fitted on first load", async ({ page }) => { await page.setViewportSize({ width: 768, height: 720 }); await mockMapShell(page); const mapState = publishedMapState([ { id: 1586, 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"); const toolbar = page.locator(".location-map-toolbar"); const modeButtons = page.locator(".location-map-mode-buttons"); const historyActions = page.locator(".location-map-history-buttons"); const zoomControls = page.locator(".location-map-zoom-controls"); const viewModeBox = await modeButtons.boundingBox(); const viewZoomBox = await zoomControls.boundingBox(); expect(viewModeBox).not.toBeNull(); expect(viewZoomBox).not.toBeNull(); if (!viewModeBox || !viewZoomBox) { throw new Error("Compact tablet toolbar layout was not measurable"); } expect(viewModeBox.width).toBeGreaterThanOrEqual(180); expect(viewModeBox.width).toBeLessThanOrEqual(190); await expect(historyActions).toHaveCount(0); expect(viewZoomBox.x).toBeGreaterThan(viewModeBox.x + viewModeBox.width - 1); await page.getByRole("button", { name: "Edit Draft" }).click(); await expect(historyActions).toHaveCount(1); const toolbarBox = await toolbar.boundingBox(); const editModeBox = await modeButtons.boundingBox(); const editHistoryBox = await historyActions.boundingBox(); const editZoomBox = await zoomControls.boundingBox(); expect(toolbarBox).not.toBeNull(); expect(editModeBox).not.toBeNull(); expect(editHistoryBox).not.toBeNull(); expect(editZoomBox).not.toBeNull(); if (!toolbarBox || !editModeBox || !editHistoryBox || !editZoomBox) { throw new Error("Compact tablet edit toolbar layout was not measurable"); } expect(Math.abs(editModeBox.x - viewModeBox.x)).toBeLessThanOrEqual(1); expect(Math.abs(editModeBox.width - viewModeBox.width)).toBeLessThanOrEqual(1); expect(editHistoryBox.x).toBeGreaterThan(editModeBox.x + editModeBox.width - 1); expect(editZoomBox.x).toBeGreaterThan(editHistoryBox.x + editHistoryBox.width - 1); expect(editZoomBox.x + editZoomBox.width).toBeLessThanOrEqual(toolbarBox.x + toolbarBox.width + 1); await expectMapFitsCanvas(page, 75, "Compact tablet"); }); test("zoom controls disable at map zoom limits", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 1591, 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"); const zoomText = page.locator(".location-map-zoom-controls span"); const zoomIn = page.getByRole("button", { name: "Zoom in" }); const zoomOut = page.getByRole("button", { name: "Zoom out" }); await expect(zoomIn).toBeEnabled(); await expect(zoomOut).toBeEnabled(); for (let index = 0; index < 6; index += 1) { await zoomIn.click(); } await expect(zoomText).toHaveText("160%"); await expect(zoomIn).toBeDisabled(); await expect(zoomOut).toBeEnabled(); for (let index = 0; index < 9; index += 1) { await zoomOut.click(); } await expect(zoomText).toHaveText("30%"); await expect(zoomOut).toBeDisabled(); await expect(zoomIn).toBeEnabled(); }); 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")).toHaveCount(0); await expect(page.locator(".location-map-mobile-tool-switch")).toHaveCount(0); const historyActions = page.locator(".location-map-history-buttons"); await expect(historyActions.getByRole("button", { name: "Undo" })).toBeVisible(); await expect(historyActions.getByRole("button", { name: "Redo" })).toBeVisible(); await expect(historyActions.getByRole("button", { name: "Undo" })).toBeDisabled(); await expect(historyActions.getByRole("button", { name: "Redo" })).toBeDisabled(); const primaryActions = page.locator(".location-map-primary-actions"); const secondaryActions = page.locator(".location-map-secondary-actions"); await expect(primaryActions.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(primaryActions.getByRole("button", { name: "Publish" })).toBeVisible(); await expect(secondaryActions.getByRole("button", { name: "Add Area" })).toBeVisible(); await expect(secondaryActions.getByRole("button", { name: "View Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "View", exact: true })).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); const secondaryActionButtons = secondaryActions.getByRole("button"); const secondaryActionButtonCount = await secondaryActionButtons.count(); for (let index = 0; index < secondaryActionButtonCount; index += 1) { const actionButtonBox = await secondaryActionButtons.nth(index).boundingBox(); expect(actionButtonBox).not.toBeNull(); if (!actionButtonBox) throw new Error("Mobile secondary action was not measurable"); expect(actionButtonBox.height).toBeGreaterThanOrEqual(40); } await page.getByRole("button", { name: "Layers" }).click(); await expect(page.getByRole("group", { name: "Layer filters" })).toBeVisible(); await page.getByRole("button", { name: "Pins" }).click(); const resetLayersButtonBox = await page.getByRole("button", { name: "Reset layer filters" }).boundingBox(); expect(resetLayersButtonBox).not.toBeNull(); if (!resetLayersButtonBox) throw new Error("Mobile layer reset button was not measurable"); expect(resetLayersButtonBox.height).toBeGreaterThanOrEqual(40); expect(resetLayersButtonBox.width).toBeLessThanOrEqual(44); await expect(page.locator(".location-map-layer-reset-icon")).toHaveCount(1); await page.getByRole("button", { name: "Reset layer filters" }).click(); await page.getByRole("button", { name: "Layers" }).click(); 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(); const sheetHandleBox = await page.locator(".location-map-bottom-sheet-handle").boundingBox(); expect(canvasBox).not.toBeNull(); expect(sheetBox).not.toBeNull(); expect(sheetHandleBox).not.toBeNull(); if (!canvasBox || !sheetBox || !sheetHandleBox) throw new Error("Mobile map layout was not measurable"); expect(canvasBox.height).toBeGreaterThan(260); expect(sheetBox.height).toBeLessThanOrEqual(360); expect(sheetHandleBox.width).toBeGreaterThanOrEqual(36); expect(sheetHandleBox.height).toBeLessThanOrEqual(6); await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); const labelInput = page.getByRole("textbox", { name: "Label" }); await expect(labelInput).toBeVisible(); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery"); await expect(page.locator(".location-map-object").last()).toHaveAttribute("data-object-key", "1601"); await expect(page.getByLabel("Object type")).toHaveCount(0); await expect(page.locator(".location-map-geometry-controls")).toHaveCount(0); await expect(page.getByLabel("X position")).toHaveCount(0); await expect(page.getByLabel("Y position")).toHaveCount(0); await expect(page.getByLabel("Width")).toHaveCount(0); await expect(page.getByLabel("Height")).toHaveCount(0); const fieldRows = page.locator(".location-map-field-row"); await expect(fieldRows).toHaveCount(2); const objectFlagButtons = page.locator(".location-map-object-flags").getByRole("button"); await expect(objectFlagButtons).toHaveCount(2); await expect(page.locator(".location-map-object-flags input[type='checkbox']")).toHaveCount(0); const visibleFlag = page.getByRole("button", { name: "Visible" }); const lockedFlag = page.getByRole("button", { name: "Locked" }); await expect(page.locator(".location-map-object-flag-state")).toHaveCount(2); await expect(visibleFlag).toHaveAttribute("aria-pressed", "true"); await expect(visibleFlag.locator(".location-map-object-flag-state.is-on")).toHaveCount(1); await expect(lockedFlag).toHaveAttribute("aria-pressed", "false"); await expect(lockedFlag.locator(".location-map-object-flag-state.is-on")).toHaveCount(0); const selectedPrimaryActions = page.locator(".location-map-selected-primary-actions"); const selectedSecondaryActions = page.locator(".location-map-selected-secondary-actions"); await expect(selectedPrimaryActions.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(selectedPrimaryActions.getByRole("button", { name: "Publish" })).toBeVisible(); await expect(selectedSecondaryActions.getByRole("button", { name: "Add Area" })).toBeVisible(); await expect(selectedSecondaryActions.getByRole("button", { name: "View Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Bought" })).toHaveCount(0); const selectedPrimaryBox = await selectedPrimaryActions.boundingBox(); const selectedSecondaryButtonBox = await selectedSecondaryActions.getByRole("button", { name: "Add Area" }).boundingBox(); const labelBox = await labelInput.boundingBox(); const firstFieldRowBox = await fieldRows.first().boundingBox(); const firstFlagButtonBox = await objectFlagButtons.first().boundingBox(); expect(selectedPrimaryBox).not.toBeNull(); expect(selectedSecondaryButtonBox).not.toBeNull(); expect(labelBox).not.toBeNull(); expect(firstFieldRowBox).not.toBeNull(); expect(firstFlagButtonBox).not.toBeNull(); if (!selectedPrimaryBox || !selectedSecondaryButtonBox || !labelBox || !firstFieldRowBox || !firstFlagButtonBox) { throw new Error("Selected object draft actions were not measurable"); } expect(selectedPrimaryBox.y).toBeLessThan(labelBox.y); expect(selectedSecondaryButtonBox.height).toBeGreaterThanOrEqual(40); expect(firstFieldRowBox.height).toBeLessThanOrEqual(54); expect(firstFlagButtonBox.height).toBeGreaterThanOrEqual(40); const closeSelectedButton = page.getByRole("button", { name: "Close selected map area" }); await expect(closeSelectedButton).toBeVisible(); const closeSelectedButtonBox = await closeSelectedButton.boundingBox(); expect(closeSelectedButtonBox).not.toBeNull(); if (!closeSelectedButtonBox) throw new Error("Selected area close button was not measurable"); expect(closeSelectedButtonBox.width).toBeGreaterThanOrEqual(40); expect(closeSelectedButtonBox.height).toBeGreaterThanOrEqual(40); await closeSelectedButton.click(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveCount(0); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Edit Areas"); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await page.getByRole("button", { name: "View", exact: true }).click(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveCount(0); }); test("mobile edit selection keeps resize handle in reach", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockMapShell(page); const mapState = draftMapState([ { id: 1606, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 720, y: 480, width: 240, 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(); const scroll = page.locator(".location-map-scroll"); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); const zoomIn = page.getByRole("button", { name: "Zoom in" }); await zoomIn.click(); await zoomIn.click(); await zoomIn.click(); await bakeryArea.focus(); await scroll.evaluate((element) => { element.scrollLeft = 0; element.scrollTop = 0; }); await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBe(0); await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBe(0); await page.keyboard.press("Enter"); await expect(bakeryArea).toHaveAttribute("aria-pressed", "true"); await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(200); await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(80); await expectResizeHandleWithinMapScroll(page); }); test("mobile edit zoom keeps selected resize handle in reach", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockMapShell(page); const mapState = draftMapState([ { id: 1608, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 720, y: 480, width: 240, 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(); const scroll = page.locator(".location-map-scroll"); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); await bakeryArea.focus(); await page.keyboard.press("Enter"); await expect(bakeryArea).toHaveAttribute("aria-pressed", "true"); await expectResizeHandleWithinMapScroll(page); await scroll.evaluate((element) => { element.scrollLeft = 0; element.scrollTop = 0; }); await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBe(0); await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBe(0); const zoomIn = page.getByRole("button", { name: "Zoom in" }); await zoomIn.click(); await zoomIn.click(); await zoomIn.click(); await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(200); await expect.poll(async () => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(80); await expectResizeHandleWithinMapScroll(page); }); test("mobile editor pans from empty map space without moving areas", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockMapShell(page); const mapState = draftMapState([ { id: 1611, 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(); const scroll = page.locator(".location-map-scroll"); const scrollBox = await scroll.boundingBox(); expect(scrollBox).not.toBeNull(); if (!scrollBox) throw new Error("Mobile edit map scroll area was not measurable"); const bakeryArea = page.locator('[data-object-key="1611"]').locator("rect").first(); await expect(bakeryArea).toHaveAttribute("x", "40"); await expect(bakeryArea).toHaveAttribute("y", "40"); const zoomIn = page.getByRole("button", { name: "Zoom in" }); await zoomIn.click(); await zoomIn.click(); await zoomIn.click(); const beforePan = await scroll.evaluate((element) => ({ left: element.scrollLeft, top: element.scrollTop, })); expect(beforePan).toEqual({ left: 0, top: 0 }); const startX = scrollBox.x + scrollBox.width - 48; const startY = scrollBox.y + scrollBox.height - 48; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX - 140, startY - 100, { steps: 8 }); await page.mouse.up(); await expect.poll(async () => scroll.evaluate((element) => element.scrollLeft) ).toBeGreaterThan(80); await expect.poll(async () => scroll.evaluate((element) => element.scrollTop) ).toBeGreaterThan(50); await expect(bakeryArea).toHaveAttribute("x", "40"); await expect(bakeryArea).toHaveAttribute("y", "40"); await expect(page.locator(".location-map-status")).toHaveText("Draft"); }); 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 can buy items from a published map", async ({ page }) => { await mockMapShell(page, memberHousehold); const memberItems = [ { id: 1721, item_name: "bagels", quantity: 1, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["map-user"], }, ]; const mapState = { ...publishedMapState([ { id: 1720, 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), items: memberItems, }; const boughtPayloads: Array> = []; 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/list/item**", async (route) => { boughtPayloads.push(await route.request().postDataJSON()); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true }), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.getByRole("button", { name: "Edit Draft" })).toHaveCount(0); await page.getByRole("button", { name: "Map area Bakery, 1 item" }).click(); await page.getByRole("button", { name: "Mark bagels bought" }).click(); await expect(page.locator(".confirm-buy-item-name")).toHaveText("bagels"); await page.getByRole("button", { name: "Mark as Bought" }).click(); await expect.poll(() => boughtPayloads.length).toBe(1); expect(boughtPayloads[0]).toMatchObject({ item_name: "bagels", bought: true, quantity_bought: 1, }); await expect(page.locator(".confirm-buy-modal")).toHaveCount(0); const hiddenZoneState = page.getByRole("group", { name: "Zone items" }).locator(".location-map-inline-state", { hasText: "Hidden" }); await expect(hiddenZoneState).toContainText("1"); await expect(page.getByRole("button", { name: "bagels bought" })).toHaveCount(0); await hiddenZoneState.getByRole("button", { name: "Show Zone Items" }).click(); await expect(page.getByRole("button", { name: "bagels bought" })).toBeDisabled(); }); 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 Published Map" })).toBeVisible(); await expect(page.getByRole("heading", { name: "No Map" })).toHaveCount(0); await expect(page.getByRole("group", { name: "Map setup details" })).toHaveCount(0); await expect(page.getByText("Not published")).toHaveCount(0); await expect(page.getByText("A household admin has not published a map yet.")).toHaveCount(0); await expect(page.getByText("Not started")).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); }); test("members do not see unpublished draft maps", async ({ page }) => { await mockMapShell(page, memberHousehold); const mapState = draftMapState([ { id: 1701, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Draft 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.getByRole("heading", { name: "No Published Map" })).toBeVisible(); await expect(page.getByRole("heading", { name: "No Map" })).toHaveCount(0); await expect(page.getByRole("group", { name: "Map setup details" })).toHaveCount(0); await expect(page.getByText("Not published")).toHaveCount(0); await expect(page.getByText("A household admin has not published a map yet.")).toHaveCount(0); await expect(page.getByRole("heading", { name: "Draft Map" })).toHaveCount(0); await expect(page.getByText("A map draft exists for this location")).toHaveCount(0); await expect(page.getByText("Draft Bakery")).toHaveCount(0); await expect(page.getByRole("button", { name: "Continue Editing" })).toHaveCount(0); await expect(page.getByLabel("Map controls")).toHaveCount(0); });