import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Card, CardContent } from "@/components/ui/card"; import { StickySubmit } from "@/components/form/sticky-submit"; import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store"; import AppLayout from "./layout"; const pushMock = vi.fn(); let pathname = "/inspections/new"; vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: vi.fn(), push: pushMock }), usePathname: () => pathname, })); let sessionMode: "online" | "offline" = "online"; let permissions: string[] = []; vi.mock("@/lib/auth/auth-context", () => ({ useAuth: () => ({ user: { id: "u1", email: "imker@example.com", name: "Test Imker", roleKey: "admin", roleName: "Admin", permissions, mfaEnabled: false, hasAvatar: false, }, isLoading: false, sessionMode, logout: vi.fn(), }), })); vi.mock("@/components/auth/logout-confirm-dialog", () => ({ LogoutConfirmDialog: ({ open, userId, onLoggedOut, }: { open: boolean; userId: string; onLoggedOut: () => void; }) => open ? (
{userId}
) : null, })); vi.mock("@/components/notification-bell", () => ({ NotificationBell: () => null })); vi.mock("@/components/offline/offline-band", () => ({ OfflineBand: ({ userId, pageContentRef, syncEnabled, }: { userId: string; pageContentRef: { current: HTMLElement | null }; syncEnabled?: boolean; }) => (
{userId}
), })); vi.mock("@/components/user-avatar", () => ({ UserAvatar: () => null })); vi.mock("@/lib/photos/store", () => ({ purgeStaleDraftPhotos: vi.fn().mockResolvedValue(0), finishHandOvers: vi.fn().mockResolvedValue(0), })); // A box with overflow auto/scroll/hidden is a scroll container. Any such // ancestor of the page content captures `position: sticky` (StickySubmit), // and since the page itself scrolls on the window, the bar would never stick. const SCROLL_CONTAINER_CLASS = /(^|\s)([a-z]+:)*overflow-(x-|y-)?(auto|scroll|hidden)(\s|$)/; function expectNoScrollContainerAbove(element: HTMLElement) { for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) { expect(ancestor.className).not.toMatch(SCROLL_CONTAINER_CLASS); } } describe("AppLayout", () => { beforeEach(() => { sessionMode = "online"; pathname = "/inspections/new"; permissions = []; pushMock.mockClear(); }); it("starts with a skip link that moves the focus to the page content", async () => { render(

Seiteninhalt

, ); const link = screen.getByRole("link", { name: "Zum Inhalt springen" }); // First in the tab order: before the sidebar and every other link. await userEvent.tab(); expect(link).toHaveFocus(); expect(link).toHaveAttribute("href", "#main-content"); await userEvent.keyboard("{Enter}"); const content = screen.getByText("Seiteninhalt").closest("#main-content"); expect(content).not.toBeNull(); expect(content).toHaveFocus(); }); it("leaves room under the page for the quick-add button on a phone, so it covers no action", () => { pathname = "/suggestions"; permissions = ["inspections:create"]; render(

Seiteninhalt

, ); const content = screen.getByText("Seiteninhalt").closest("[tabindex='-1']") as HTMLElement; // FAB top edge = nav + 1rem gap + 3rem button = nav + 4rem; padding nav + 4.5rem leaves 0.5rem free // below the last button of the page, so the FAB never covers it at the end of the scroll. expect(content.className).toContain("pb-[calc(var(--bottom-nav-height)+--spacing(18))]"); expect(18 * 0.25).toBeGreaterThan(1 + 3); }); it("adds no extra room on a form page (the quick-add button is hidden there) or without the button", () => { permissions = ["inspections:create"]; const { unmount } = render(

Seiteninhalt

, ); expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain( "--spacing(18)", ); unmount(); pathname = "/suggestions"; permissions = []; render(

Seiteninhalt

, ); expect(screen.getByText("Seiteninhalt").closest("[tabindex='-1']")?.className).not.toContain( "--spacing(18)", ); }); it("shows the offline band for the signed-in user right below the header", () => { render(

Seiteninhalt

, ); const band = screen.getByTestId("offline-band"); expect(band).toHaveTextContent("u1"); expect(band.previousElementSibling?.tagName).toBe("HEADER"); }); it("lets the band send only in an online session", () => { const { unmount } = render(

Seiteninhalt

, ); expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "true"); unmount(); sessionMode = "offline"; render(

Seiteninhalt

, ); expect(screen.getByTestId("offline-band")).toHaveAttribute("data-sync-enabled", "false"); }); it("asks through the logout dialog and moves to the login page once signed out", async () => { render(

Seiteninhalt

, ); expect(screen.queryByRole("dialog", { name: "Abmelden?" })).not.toBeInTheDocument(); await userEvent.click(screen.getByRole("button", { name: "Konto-MenĂ¼" })); await userEvent.click(await screen.findByRole("menuitem", { name: "Abmelden" })); const dialog = await screen.findByRole("dialog", { name: "Abmelden?" }); expect(dialog).toHaveTextContent("u1"); await userEvent.click(screen.getByRole("button", { name: "Abgemeldet" })); expect(pushMock).toHaveBeenCalledWith("/login"); }); it("gives the band the page content wrapper as a stable focus target", () => { render(

Seiteninhalt

, ); fireEvent.click(screen.getByRole("button", { name: "Fokus auf den Seiteninhalt" })); const wrapper = screen.getByText("Seiteninhalt").closest("[tabindex='-1']"); expect(wrapper).not.toBeNull(); expect(wrapper).toHaveFocus(); expect(wrapper?.previousElementSibling).toBe(screen.getByTestId("offline-band")); }); it("deletes photos of form drafts older than 3 days when the app starts", async () => { vi.mocked(purgeStaleDraftPhotos).mockClear(); const before = Date.now(); render(

Seiteninhalt

, ); await waitFor(() => expect(purgeStaleDraftPhotos).toHaveBeenCalledTimes(1)); const [now] = vi.mocked(purgeStaleDraftPhotos).mock.calls[0]; expect(now).toBeGreaterThanOrEqual(before); }); it("finishes the signed-in user's open photo hand-overs when the app starts", async () => { vi.mocked(finishHandOvers).mockClear(); render(

Seiteninhalt

, ); // Even offline: the photos then wait as entries of their own. await waitFor(() => expect(finishHandOvers).toHaveBeenCalledTimes(1)); expect(finishHandOvers).toHaveBeenCalledWith("u1"); }); it("does not wrap the page content in its own scroll container", () => { render(

Seiteninhalt

, ); expectNoScrollContainerAbove(screen.getByText("Seiteninhalt")); }); it("keeps every ancestor of a form's save bar, including the Card, free of scroll containers", () => { render(
, ); const saveBar = screen.getByRole("button", { name: "Speichern" }).parentElement!; expectNoScrollContainerAbove(saveBar); }); });