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 Map From Existing Zones" })).toBeVisible(); await expect(page.getByLabel("Map controls")).toHaveCount(0); await page.getByRole("button", { name: "Create Map From Existing Zones" }).click(); await expect(page.getByText("Bakery")).toBeVisible(); await expect(page.getByText("Frozen Foods")).toBeVisible(); await expect(page.getByRole("button", { name: "Pan Mode" })).toBeVisible(); await expect(page.getByRole("button", { name: "Edit Objects" })).toBeVisible(); await expect(page.locator(".location-map-pin")).toHaveCount(0); await page.getByRole("button", { name: "Pan Mode" }).click(); const bakeryPanObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first(); const panBox = await bakeryPanObject.boundingBox(); expect(panBox).not.toBeNull(); if (!panBox) throw new Error("Bakery map object was not measurable"); await page.mouse.move(panBox.x + panBox.width / 2, panBox.y + panBox.height / 2); await page.mouse.down(); await page.mouse.move(panBox.x + panBox.width / 2 - 80, panBox.y + panBox.height / 2 - 20, { steps: 8, }); await page.mouse.up(); await page.getByRole("button", { name: "Edit Objects" }).click(); const bakeryObject = page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first(); await bakeryObject.click(); const moveBox = await bakeryObject.boundingBox(); expect(moveBox).not.toBeNull(); if (!moveBox) throw new Error("Bakery map object could not be moved"); await page.mouse.move(moveBox.x + moveBox.width / 2, moveBox.y + moveBox.height / 2); await page.mouse.down(); await page.mouse.move(moveBox.x + moveBox.width / 2 + 60, moveBox.y + moveBox.height / 2 + 40, { steps: 8, }); await page.mouse.up(); const resizeHandle = page.locator(".location-map-resize-handle"); await expect(resizeHandle).toBeVisible(); 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 page.getByRole("button", { name: "Duplicate" }).click(); await expect(page.getByRole("textbox", { name: "Label" })).toHaveValue("Bread Wall Copy"); await page.getByLabel("Linked zone").selectOption("502"); await page.getByRole("textbox", { name: "Label" }).fill("Temporary Freezer"); await page.getByRole("button", { name: "Delete" }).click(); await confirmSlide(page); await expect(page.locator(".location-map-object", { hasText: "Temporary Freezer" })).toHaveCount(0); await page.getByRole("button", { name: "Back" }).click(); await expect(page.getByRole("heading", { name: "Leave with unsaved changes?" })).toBeVisible(); await page.getByRole("button", { name: "Cancel" }).click(); await expect(page).toHaveURL(/\/stores\/100\/locations\/10\/map$/); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await page.getByRole("button", { name: "Preview Draft" }).click(); await expect(page.locator(".location-map-object", { hasText: "Bread Wall" })).toBeVisible(); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await page.getByRole("button", { name: "Edit Draft" }).click(); await page.getByRole("button", { name: "Edit Objects" }).click(); await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click(); await page.getByRole("button", { name: "Save Draft" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Draft"); expect(savedPayload).not.toBeNull(); const savedObjects = savedPayload?.objects as Array>; const savedBreadWall = savedObjects.find((object) => object.label === "Bread Wall"); expect(savedBreadWall).toEqual(expect.objectContaining({ zone_id: 501 })); expect(Number(savedBreadWall?.x)).toBeGreaterThan(28); expect(Number(savedBreadWall?.y)).toBeGreaterThan(28); expect(Number(savedBreadWall?.width)).toBeGreaterThan(300); expect(Number(savedBreadWall?.height)).toBeGreaterThan(180); expect(savedObjects.some((object) => object.label === "Temporary Freezer")).toBe(false); await page.goto("/login"); await expect(page.getByRole("heading", { name: "Login" })).toBeVisible(); await page.goto("/stores/100/locations/10/map"); await expect(page.locator(".location-map-status")).toHaveText("Draft"); await expect(page.getByRole("heading", { name: "Draft Map" })).toBeVisible(); await page.getByRole("button", { name: "Continue Editing" }).click(); await expect(page.locator(".location-map-object", { hasText: "Bread Wall" })).toBeVisible(); await expect(page.locator(".location-map-object", { hasText: "Temporary Freezer" })).toHaveCount(0); await page.getByRole("button", { name: "Publish" }).click(); await expect(page.locator(".location-map-status")).toHaveText("Published"); await expect(page.getByRole("button", { name: "Edit Map" })).toBeVisible(); await expect(page.getByText("loose batteries")).toHaveCount(0); await page.locator(".location-map-object", { hasText: "Bread Wall" }).locator("rect").first().click(); const zonePanel = page.locator(".location-map-zone-items"); await expect(zonePanel.locator(".location-map-zone-items-title span")).toHaveText("2 items"); await expect(page.getByText("french bread")).toBeVisible(); await expect(page.getByText("sourdough")).toBeVisible(); await expect(page.getByText("frozen salmon")).toHaveCount(0); await page.getByRole("button", { name: "Layers" }).click(); await page.getByLabel("Unmapped").check(); await expect(page.getByText("loose batteries")).toBeVisible(); }); test("admin 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("viewer wraps long zone labels and keeps item counts visible", async ({ page }) => { await mockMapShell(page); const mapState = publishedMapState([ { id: 1501, location_map_id: 901, zone_id: 501, zone_name: "Produce & Fresh Vegetables", type: "zone", label: "Produce & Fresh Vegetables", x: 40, y: 40, width: 260, height: 120, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); const produceArea = page.locator('[data-object-key="1501"]'); await expect(produceArea.locator(".location-map-label tspan")).toHaveCount(2); await expect(produceArea.locator(".location-map-label tspan").nth(0)).toHaveText("Produce & Fresh"); await expect(produceArea.locator(".location-map-label tspan").nth(1)).toHaveText("Vegetables"); await expect(produceArea.locator(".location-map-count")).toHaveText("2 items"); await expect(produceArea.locator(".location-map-count")).toHaveAttribute("y", "116"); await expect(page.locator(".location-map-pin")).toHaveCount(0); await page.getByRole("button", { name: "Layers" }).click(); await page.getByLabel("Labels").uncheck(); await expect(produceArea.locator(".location-map-label")).toHaveCount(0); await expect(produceArea.locator(".location-map-count")).toHaveText("2 items"); await expect(produceArea.locator(".location-map-count")).toHaveAttribute("y", "92"); }); test("mobile editor uses bottom sheet controls instead of desktop object toolbar", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockMapShell(page); const mapState = draftMapState([ { id: 1601, location_map_id: 900, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], true); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.getByRole("heading", { name: "Costco" })).toBeVisible(); await expect(page.getByText("Eastvale")).toBeVisible(); await expect(page.getByRole("heading", { name: "Draft Map" })).toBeVisible(); await page.getByRole("button", { name: "Continue Editing" }).click(); await expect(page.getByLabel("Map controls")).toBeVisible(); await expect(page.locator(".location-map-tool-buttons")).toBeHidden(); await expect(page.locator(".location-map-mobile-tool-switch")).toBeVisible(); await expect(page.getByRole("button", { name: "Pan" })).toHaveClass(/active/); await expect(page.getByRole("button", { name: "Pan" })).toHaveClass(/is-pan/); await expect(page.getByRole("button", { name: "Objects" })).toHaveClass(/is-edit/); await expect(page.locator(".location-map-pin")).toHaveCount(0); const canvasBox = await page.locator(".location-map-canvas-shell").boundingBox(); const sheetBox = await page.locator(".location-map-bottom-sheet").boundingBox(); expect(canvasBox).not.toBeNull(); expect(sheetBox).not.toBeNull(); if (!canvasBox || !sheetBox) throw new Error("Mobile map layout was not measurable"); expect(canvasBox.height).toBeGreaterThan(260); expect(sheetBox.height).toBeLessThanOrEqual(360); await page.getByRole("button", { name: "Objects" }).click(); await expect(page.getByRole("button", { name: "Objects" })).toHaveClass(/active/); await page.locator(".location-map-object", { hasText: "Bakery" }).locator("rect").first().click(); await expect(page.getByRole("textbox", { name: "Label" })).toBeVisible(); }); test("members can view a published map but cannot edit it", async ({ page }) => { await mockMapShell(page, memberHousehold); const mapState = publishedMapState([ { id: 1201, location_map_id: 901, zone_id: 501, zone_name: "Bakery", type: "zone", label: "Bakery", x: 40, y: 40, width: 260, height: 160, rotation: 0, locked: false, visible: true, sort_order: 1, }, ], false); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(mapState), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.getByText("Published")).toBeVisible(); await expect(page.getByText("Bakery")).toBeVisible(); await expect(page.getByRole("button", { name: "Edit Map" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Publish" })).toHaveCount(0); }); test("members see a clean empty state when no map is published", async ({ page }) => { await mockMapShell(page, memberHousehold); await page.route("**/households/1/locations/10/map", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(noMapState(false)), }); }); await page.goto("/stores/100/locations/10/map"); await expect(page.getByRole("heading", { name: "No Map" })).toBeVisible(); await expect(page.getByText("A household admin has not published a map yet.")).toBeVisible(); await expect(page.getByText("Create a rough map for this store location.")).toHaveCount(0); await expect(page.getByRole("button", { name: "Create Map From Existing Zones" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Create Blank Map" })).toHaveCount(0); await expect(page.getByLabel("Map controls")).toHaveCount(0); });