),
}));
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(
,
);
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(
,
);
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(
,
);
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);
});
});