import { expect, test, type Page } from "@playwright/test"; import { confirmSlide, mockConfig, seedAuthStorage } from "./helpers/e2e"; const adminHousehold = { id: 1, name: "Map House", role: "admin", invite_code: "ABCD1234" }; const memberHousehold = { ...adminHousehold, role: "member" }; const storeLocation = { id: 10, household_store_id: 100, household_id: 1, name: "Costco", location_name: "Eastvale", display_name: "Costco - Eastvale", is_default: true, }; const zones = [ { id: 501, name: "Bakery", sort_order: 10, item_count: 2 }, { id: 502, name: "Frozen Foods", sort_order: 20, item_count: 1 }, ]; const items = [ { id: 1, item_name: "french bread", quantity: 1, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["map-user"], }, { id: 2, item_name: "sourdough", quantity: 1, bought: false, zone_id: 501, zone: "Bakery", added_by_users: ["map-user"], }, { id: 3, item_name: "frozen salmon", quantity: 1, bought: false, zone_id: 502, zone: "Frozen Foods", added_by_users: ["other-user"], }, { id: 4, item_name: "loose batteries", quantity: 1, bought: false, zone_id: null, zone: null, added_by_users: ["map-user"], }, ]; function noMapState(canManage = true) { return { location: storeLocation, map: null, draft_map: null, published_map: null, objects: [], draft_objects: [], published_objects: [], zones, items, unmapped_count: 1, can_manage: canManage, }; } function draftMapState(objects: Array>, 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 = adminHousehold) { await seedAuthStorage(page, { username: "map-user", role: household.role }); await mockConfig(page); await page.route("**/households", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([household]), }); }); await page.route("**/households/1/stores", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([storeLocation]), }); }); await page.route("**/households/1/locations/10/zones", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ zones }), }); }); } test("admin creates a map from zones, saves a draft, and publishes it", async ({ page }) => { await mockMapShell(page); let mapState = noMapState(true); let savedPayload: Record | 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: "Pan Mode" })).toBeVisible(); await expect(page.getByRole("button", { name: "Edit Objects" })).toBeVisible(); await expect(page.locator(".location-map-pin")).toHaveCount(0); await page.getByRole("button", { name: "Pan Mode" }).click(); const bakeryPanObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first(); const panBox = await bakeryPanObject.boundingBox(); expect(panBox).not.toBeNull(); if (!panBox) throw new Error("Bakery map object was not measurable"); await page.mouse.move(panBox.x + panBox.width / 2, panBox.y + panBox.height / 2); await page.mouse.down(); await page.mouse.move(panBox.x + panBox.width / 2 - 80, panBox.y + panBox.height / 2 - 20, { steps: 8, }); await page.mouse.up(); await page.getByRole("button", { name: "Edit Objects" }).click(); const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first(); await bakeryObject.click(); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery"); 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" }).click(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bread Wall Copy"); await page.getByLabel("Linked zone").selectOption("502"); await page.getByRole("textbox", { name: "Label" }).fill("Temporary Freezer"); await page.getByRole("button", { name: "Delete" }).click(); await confirmSlide(page); await expect(page.locator(".location-map-object", { hasText: "Temporary Freezer" })).toHaveCount(0); await page.getByRole("button", { name: "Back 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$/); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await page.getByRole("button", { name: "Preview Draft" }).click(); await expect(page.locator(".location-map-object", { hasText: "Bread Wall" })).toBeVisible(); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await page.getByRole("button", { name: "Edit Draft" }).click(); await page.getByRole("button", { name: "Edit Objects" }).click(); await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click(); await page.getByRole("button", { name: "Save Draft" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); expect(savedPayload).not.toBeNull(); const savedObjects = savedPayload?.objects as Array>; 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: "Preview 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(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")).toBeVisible(); }); test("admin setup explains when no zones exist yet", async ({ page }) => { await mockMapShell(page); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...noMapState(true), zones: [], items: [], unmapped_count: 0, }), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible(); await expect(page.getByText("No zones are available yet.")).toBeVisible(); await expect(page.getByRole("button", { name: "Create From Zones" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Create Blank Map" })).toBeEnabled(); const blankButtonBox = await page.getByRole("button", { name: "Create Blank Map" }).boundingBox(); const fromZonesButtonBox = await page.getByRole("button", { name: "Create From Zones" }).boundingBox(); expect(blankButtonBox).not.toBeNull(); expect(fromZonesButtonBox).not.toBeNull(); if (!blankButtonBox || !fromZonesButtonBox) throw new Error("No-zone setup actions were not measurable"); expect(blankButtonBox.y).toBeLessThan(fromZonesButtonBox.y); await expect(page.getByLabel("Map controls")).toHaveCount(0); }); test("admin 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 status follows the visible map when a saved draft exists", async ({ page }) => { await mockMapShell(page); const publishedObjects = [ { id: 1201, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Live Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ]; const draftObjects = [ { ...publishedObjects[0], id: 1301, location_map_id: 900, label: "Draft Bakery", x: 80, y: 80, }, ]; const mapState = draftAndPublishedMapState(draftObjects, publishedObjects, true); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.locator(".location-map-status")).toHaveText("Published"); await expect(page.locator(".location-map-object", { hasText: "Live Bakery" })).toBeVisible(); await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toHaveCount(0); await page.getByRole("button", { name: "Edit Draft" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toBeVisible(); await page.getByRole("button", { name: "Preview Draft" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Draft Preview"); await expect(page.locator(".location-map-object", { hasText: "Draft Bakery" })).toBeVisible(); await page.getByRole("button", { name: "View" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Published"); await expect(page.locator(".location-map-object", { hasText: "Live Bakery" })).toBeVisible(); }); test("mobile keeps draft preview status compact in the topbar", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockMapShell(page); const publishedObjects = [ { id: 1251, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Live Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ]; const draftObjects = [ { ...publishedObjects[0], id: 1252, location_map_id: 900, label: "Draft Bakery", x: 80, y: 80, }, ]; await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(draftAndPublishedMapState(draftObjects, publishedObjects, true)), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Edit Draft" }).click(); await page.getByRole("button", { name: "Preview Draft" }).click(); const topbar = page.locator(".location-map-topbar"); const backButton = page.getByRole("button", { name: "Back to grocery list" }); const status = page.locator(".location-map-status"); const toolbar = page.locator(".location-map-toolbar"); const modeButtons = page.locator(".location-map-mode-buttons"); const zoomControls = page.locator(".location-map-zoom-controls"); await expect(status).toHaveText("Draft Preview"); const topbarBox = await topbar.boundingBox(); const backButtonBox = await backButton.boundingBox(); const titleBox = await page.locator(".location-map-title").boundingBox(); const statusBox = await status.boundingBox(); const toolbarBox = await toolbar.boundingBox(); const modeBox = await modeButtons.boundingBox(); const zoomBox = await zoomControls.boundingBox(); expect(topbarBox).not.toBeNull(); expect(backButtonBox).not.toBeNull(); expect(titleBox).not.toBeNull(); expect(statusBox).not.toBeNull(); expect(toolbarBox).not.toBeNull(); expect(modeBox).not.toBeNull(); expect(zoomBox).not.toBeNull(); if (!topbarBox || !backButtonBox || !titleBox || !statusBox || !toolbarBox || !modeBox || !zoomBox) { throw new Error("Mobile map controls layout was not measurable"); } expect(topbarBox.height).toBeLessThanOrEqual(66); expect(backButtonBox.width).toBeLessThanOrEqual(42); expect(statusBox.y).toBeLessThan(titleBox.y + titleBox.height); expect(statusBox.x).toBeGreaterThan(titleBox.x); expect(toolbarBox.height).toBeLessThanOrEqual(64); expect(zoomBox.y).toBeLessThan(modeBox.y + modeBox.height); }); test("admin selecting an object does not mark a draft dirty until it changes", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1351, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Continue Editing" }).click(); await page.getByRole("button", { name: "Edit Objects" }).click(); const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first(); await bakeryObject.click(); await expect(page.getByRole("textbox", { name: "Label" })).toBeVisible(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled(); const objectBox = await bakeryObject.boundingBox(); expect(objectBox).not.toBeNull(); if (!objectBox) throw new Error("Bakery map object was not measurable"); await page.mouse.move(objectBox.x + objectBox.width / 2, objectBox.y + objectBox.height / 2); await page.mouse.down(); await page.mouse.move(objectBox.x + objectBox.width / 2 + 50, objectBox.y + objectBox.height / 2 + 30, { steps: 8, }); await page.mouse.up(); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled(); }); test("admin 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: "Edit Objects" }).click(); await page.getByRole("button", { name: "Map area Bakery" }).click(); await expect(page.getByLabel("Visible")).toBeChecked(); await expect(page.getByLabel("Locked")).not.toBeChecked(); await page.getByLabel("Visible").click(); await expect(page.getByRole("button", { name: "Map area Bakery" })).toHaveCount(0); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Edit Objects"); 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)" })).toBeVisible(); 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 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(); await page.getByRole("button", { name: "Edit Objects" }).click(); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); await bakeryArea.click(); await expect(page.locator(".location-map-resize-handle")).toBeVisible(); await expect(page.getByRole("spinbutton", { name: "X" })).toHaveCount(0); await expect(page.getByLabel("Locked")).not.toBeChecked(); await page.getByLabel("Locked").click(); await expect(page.getByLabel("Locked")).toBeChecked(); await expect(page.getByRole("textbox", { name: "Label" })).toBeEnabled(); await expect(page.getByLabel("Visible")).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.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 page.getByLabel("Locked").click(); await expect(page.getByLabel("Locked")).not.toBeChecked(); await expect(page.locator(".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: "Edit Objects" }).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" }).click(); await expect(page.getByRole("heading", { name: "Delete Bakery?" })).toBeVisible(); await page.getByRole("button", { name: "Cancel" }).click(); await page.getByRole("button", { name: "Duplicate" }).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 nudges selected map areas with arrow keys", async ({ page }) => { await mockMapShell(page); const mapState = draftMapState([ { id: 1352, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Continue Editing" }).click(); await page.getByRole("button", { name: "Edit Objects" }).click(); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); await bakeryArea.focus(); await expect(bakeryArea).toBeFocused(); await page.keyboard.press("Enter"); await expect(page.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 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.getByRole("button", { name: "Edit Objects" }).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); }); test("admin save progress locks draft controls", async ({ page }) => { await mockMapShell(page); let mapState = draftMapState([ { id: 1361, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); let releaseSave: () => void = () => {}; let saveStarted = false; const saveGate = new Promise((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.getByRole("button", { name: "Edit Objects" }).click(); await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await page.getByRole("textbox", { name: "Label" }).fill("Saving Bakery"); await page.getByRole("button", { name: "Save Draft" }).click(); await expect.poll(() => saveStarted).toBe(true); await expect(page.getByRole("button", { name: "Saving..." })).toBeDisabled(); await expect(page.getByRole("button", { name: "Publish" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Add Area" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Preview Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Undo" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Duplicate" })).toBeDisabled(); await expect(page.getByRole("button", { name: "Delete" })).toBeDisabled(); await expect(page.getByRole("textbox", { name: "Label" })).toBeDisabled(); releaseSave(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("button", { name: "Save Draft" })).toBeDisabled(); }); test("admin 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.getByRole("button", { name: "Edit Objects" }).click(); await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await page.getByLabel("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)" })).toBeEnabled(); 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)" })).toBeDisabled(); 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.getByRole("button", { name: "Edit Objects" }).click(); await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await page.getByRole("textbox", { name: "Label" }).fill("Hard Navigation Bakery"); const dirtyBeforeUnload = await page.evaluate(() => { const event = new Event("beforeunload", { cancelable: true }); const dispatchResult = window.dispatchEvent(event); return { defaultPrevented: event.defaultPrevented, dispatchResult, }; }); expect(dirtyBeforeUnload).toEqual({ defaultPrevented: true, dispatchResult: false, }); }); test("admin publish saves pending map edits before publishing", async ({ page }) => { await mockMapShell(page); let mapState = publishedMapState([ { id: 1401, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Live Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); let savedPayload: Record | 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.getByRole("button", { name: "Edit Objects" }).click(); await page.locator(".location-map-object", { hasText: "Live Bakery" }).locator("rect").first().click(); await page.getByRole("textbox", { name: "Label" }).fill("Quick Publish Bakery"); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await page.getByRole("button", { name: "Publish", exact: true }).click(); await expect.poll(() => publishCalled).toBe(true); expect(savedPayload).not.toBeNull(); expect(savedPayload?.objects).toEqual( expect.arrayContaining([ expect.objectContaining({ label: "Quick Publish Bakery", zone_id: 501, }), ]) ); await expect(page.locator(".location-map-status")).toHaveText("Published"); await expect(page.getByRole("button", { name: "Map area Quick Publish Bakery" })).toBeVisible(); await expect(page.getByRole("button", { name: "Map area Live Bakery" })).toHaveCount(0); }); test("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.getByRole("button", { name: "Edit Objects" }).click(); await page.locator(".location-map-object", { hasText: "Live Bakery" }).locator("rect").first().click(); await page.getByRole("textbox", { name: "Label" }).fill("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" })).toBeDisabled(); 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 labelsToggle = page.getByRole("button", { name: "Labels" }); await expect(labelsToggle).toHaveAttribute("aria-pressed", "true"); await labelsToggle.click(); await expect(labelsToggle).toHaveAttribute("aria-pressed", "false"); 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 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("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.getByLabel("3 more unmapped items")).toHaveText("+3 more"); }); test("viewer explains 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); await expect(page.getByText("Unmapped items are hidden by layer filters.")).toBeVisible(); await expect(page.getByRole("button", { name: "Show Unmapped Items" })).toBeVisible(); await page.getByRole("button", { name: "Show Unmapped Items" }).click(); await expect(page.getByText("other batteries")).toBeVisible(); await expect(page.getByText("Unmapped items are hidden by layer filters.")).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); await expect(page.getByText("1 more hidden by layer filters.")).toBeVisible(); await expect(page.getByRole("button", { name: "Show All Unmapped Items" })).toBeVisible(); await page.getByRole("button", { name: "Show All Unmapped Items" }).click(); await expect(page.getByText("hidden batteries")).toBeVisible(); await expect(page.getByText("1 more hidden by layer filters.")).toHaveCount(0); }); test("viewer explains when selected zone items are hidden by filters", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 1551, location_map_id: 901, zone_id: 502, zone_name: "Frozen Foods", type: "zone", label: "Frozen Foods", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); const frozenArea = page.locator('[data-object-key="1551"]'); await expect(frozenArea.locator(".location-map-count")).toHaveText("0 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"); await expect(page.getByText("Items are assigned here, but hidden by layer filters.")).toBeVisible(); await expect(page.getByText("frozen salmon")).toHaveCount(0); await page.getByRole("button", { name: "Show Zone Items" }).click(); await expect(page.locator(".location-map-sheet-count")).toHaveText("1 item"); await expect(page.getByText("frozen salmon")).toBeVisible(); await expect(frozenArea.locator(".location-map-count")).toHaveText("1 item"); await expect(page.locator(".location-map-pin")).toHaveCount(0); }); test("viewer 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); await expect(page.getByText("1 more hidden by layer filters.")).toBeVisible(); await expect(page.getByRole("button", { name: "Show All Zone Items" })).toBeVisible(); 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.getByText("No items assigned to this zone yet.")).toBeVisible(); await page.locator('[data-object-key="1561"]').locator("rect").first().click(); await expect(page.locator(".location-map-sheet-count")).toHaveText("14 items"); await expect(page.locator(".location-map-zone-items li")).toHaveCount(14); await expect(page.locator(".location-map-zone-items li").first()).toContainText("bulk item 1"); await expect(page.locator(".location-map-zone-items li").last()).toContainText("bulk item 14"); await expect(page.locator(".location-map-pin")).toHaveCount(0); }); test("viewer map areas are keyboard selectable", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 1571, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, { id: 1572, location_map_id: 901, zone_id: 502, zone_name: "Frozen Foods", type: "zone", label: "Frozen Foods", x: 340, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 2, }, ], true); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); const bakeryArea = page.getByRole("button", { name: "Map area Bakery" }); const frozenArea = page.getByRole("button", { name: "Map area Frozen Foods" }); await expect(bakeryArea).toHaveAttribute("aria-pressed", "false"); await bakeryArea.focus(); await expect(bakeryArea).toBeFocused(); await page.keyboard.press("Enter"); await expect(bakeryArea).toHaveAttribute("aria-pressed", "true"); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Bakery"); await expect(page.locator(".location-map-sheet-count")).toHaveText("2 items"); await expect(page.getByText("french bread")).toBeVisible(); await frozenArea.focus(); await expect(frozenArea).toBeFocused(); await page.keyboard.press("Space"); await expect(frozenArea).toHaveAttribute("aria-pressed", "true"); await expect(bakeryArea).toHaveAttribute("aria-pressed", "false"); await expect(page.locator(".location-map-sheet-header strong")).toHaveText("Frozen Foods"); await expect(page.locator(".location-map-sheet-count")).toHaveText("0 shown"); }); test("mobile fit zoom fits the map into the visible canvas", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockMapShell(page); const mapState = publishedMapState([ { id: 1581, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); await page.getByRole("button", { name: "Fit" }).click(); const zoomText = page.locator(".location-map-zoom-controls span"); await expect.poll(async () => { const value = await zoomText.textContent(); return Number(value?.replace("%", "") || 0); }).toBeLessThan(45); const scrollBox = await page.locator(".location-map-scroll").boundingBox(); const svgBox = await page.locator(".location-map-svg").boundingBox(); expect(scrollBox).not.toBeNull(); expect(svgBox).not.toBeNull(); if (!scrollBox || !svgBox) throw new Error("Mobile fitted map was not measurable"); expect(svgBox.width).toBeLessThanOrEqual(scrollBox.width + 1); expect(svgBox.height).toBeLessThanOrEqual(scrollBox.height + 1); }); test("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")).toBeHidden(); 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" })).toBeVisible(); await expect(primaryActions.getByRole("button", { name: "Publish" })).toBeVisible(); await expect(secondaryActions.getByRole("button", { name: "Add Area" })).toBeVisible(); await expect(secondaryActions.getByRole("button", { name: "Preview Draft" })).toBeVisible(); const primaryActionBox = await primaryActions.boundingBox(); const secondaryActionBox = await secondaryActions.boundingBox(); expect(primaryActionBox).not.toBeNull(); expect(secondaryActionBox).not.toBeNull(); if (!primaryActionBox || !secondaryActionBox) throw new Error("Mobile action groups were not measurable"); expect(primaryActionBox.y).toBeLessThan(secondaryActionBox.y); await expect(page.locator(".location-map-pin")).toHaveCount(0); const canvasBox = await page.locator(".location-map-canvas-shell").boundingBox(); const sheetBox = await page.locator(".location-map-bottom-sheet").boundingBox(); expect(canvasBox).not.toBeNull(); expect(sheetBox).not.toBeNull(); if (!canvasBox || !sheetBox) throw new Error("Mobile map layout was not measurable"); expect(canvasBox.height).toBeGreaterThan(260); expect(sheetBox.height).toBeLessThanOrEqual(360); await page.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 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" })).toBeVisible(); await expect(selectedPrimaryActions.getByRole("button", { name: "Publish" })).toBeVisible(); await expect(selectedSecondaryActions.getByRole("button", { name: "Add Area" })).toBeVisible(); await expect(selectedSecondaryActions.getByRole("button", { name: "Preview Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Done" })).toHaveCount(0); const selectedPrimaryBox = await selectedPrimaryActions.boundingBox(); const labelBox = await labelInput.boundingBox(); const firstFieldRowBox = await fieldRows.first().boundingBox(); expect(selectedPrimaryBox).not.toBeNull(); expect(labelBox).not.toBeNull(); expect(firstFieldRowBox).not.toBeNull(); if (!selectedPrimaryBox || !labelBox || !firstFieldRowBox) { throw new Error("Selected object draft actions were not measurable"); } expect(selectedPrimaryBox.y).toBeLessThan(labelBox.y); expect(firstFieldRowBox.height).toBeLessThanOrEqual(54); await page.getByRole("button", { name: "View" }).click(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveCount(0); }); test("members can view a published map but cannot edit it", async ({ page }) => { await mockMapShell(page, memberHousehold); const mapState = publishedMapState([ { id: 1201, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], false); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.getByText("Published")).toBeVisible(); await expect(page.getByText("Bakery")).toBeVisible(); await expect(page.getByRole("button", { name: "Edit Map" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Publish" })).toHaveCount(0); }); test("members see a clean empty state when no map is published", async ({ page }) => { await mockMapShell(page, memberHousehold); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(noMapState(false)), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible(); await expect(page.getByText("A household admin has not published a map yet.")).toBeVisible(); await expect(page.getByText("Create a rough map for this store location.")).toHaveCount(0); await expect(page.getByRole("button", { name: /Create From/ })).toHaveCount(0); await expect(page.getByRole("button", { name: "Create Blank Map" })).toHaveCount(0); await expect(page.getByLabel("Map controls")).toHaveCount(0); }); 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 Map" })).toBeVisible(); await expect(page.getByText("A household admin has not published a map yet.")).toBeVisible(); 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); });