import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { NextStepsCard } from "./next-steps-card"; import { colonyNextStepsKey, fetchColonyNextSteps } from "@/lib/autopilot/api"; import { colonyStepsFixture, stepFixture } from "@/lib/autopilot/step-fixtures"; import { acceptSuggestion, bulkDismissSuggestions, reopenSuggestion } from "@/lib/suggestions/api"; import { ApiError } from "@/lib/auth/api-client"; import { reportRequestOutcome } from "@/lib/offline/connectivity"; import { toast } from "sonner"; vi.mock("@/lib/autopilot/api", async (importOriginal) => ({ ...(await importOriginal()), fetchColonyNextSteps: vi.fn(), })); vi.mock("@/lib/suggestions/api", async (importOriginal) => ({ ...(await importOriginal()), acceptSuggestion: vi.fn(), bulkDismissSuggestions: vi.fn(), reopenSuggestion: vi.fn(), })); vi.mock("@/lib/auth/auth-context", () => ({ useAuth: () => ({ hasPermission: () => true }), })); vi.mock("@/components/suggestions/cleanup-sheet", () => ({ CleanupSheet: ({ suggestionId }: { suggestionId: string | null }) => suggestionId ?

Aufräumen-Sheet {suggestionId}

: null, })); vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }), })); function renderCard() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( , ); } const TASK_STEP = stepFixture({ key: "task:t2", kind: "TASK", title: "Zwei", reasons: [], suggestionIds: [], action: { type: "OPEN_TASK", taskId: "t2", href: "/tasks" }, }); describe("NextStepsCard", () => { afterEach(() => { act(() => reportRequestOutcome("reached-server")); }); beforeEach(() => { vi.mocked(fetchColonyNextSteps).mockReset(); vi.mocked(acceptSuggestion).mockReset(); vi.mocked(bulkDismissSuggestions).mockReset(); }); it("NextStepsCard shows at most 3 steps with reasons and links CAPTURE to the form with colonyId", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue( colonyStepsFixture({ steps: [ stepFixture({ key: "suggestion:s1", title: "Durchsicht" }), TASK_STEP, stepFixture({ key: "suggestion:s3", title: "Drei", suggestionIds: ["s3"] }), stepFixture({ key: "suggestion:s4", title: "Vier", suggestionIds: ["s4"] }), ], }), ); renderCard(); expect(await screen.findByText("Durchsicht")).toBeInTheDocument(); expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(3); expect(screen.queryByText("Vier")).not.toBeInTheDocument(); expect(screen.getAllByText("Letzte Durchsicht vor 8 Tagen.").length).toBeGreaterThan(0); expect(screen.getAllByText("In der Schwarmzeit alle 7 Tage.").length).toBeGreaterThan(0); const first = screen.getAllByRole("heading", { level: 3 })[0].closest("li") as HTMLElement; expect(within(first).getByRole("link", { name: "Erfassen" })).toHaveAttribute( "href", "/inspections/new?colonyId=c1", ); expect(screen.getByRole("link", { name: "Aufgabe öffnen" })).toHaveAttribute("href", "/tasks"); }); it("NextStepsCard shows the withdrawal hint, not a step", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue( colonyStepsFixture({ steps: [], withdrawalUntil: "2026-05-05" }), ); renderCard(); expect(await screen.findByText("Wartezeit bis 05.05.")).toBeInTheDocument(); expect(screen.queryAllByRole("listitem")).toHaveLength(0); }); it("accepts a step with one tap and an idempotency key", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue( colonyStepsFixture({ steps: [ stepFixture({ intent: "HONEY_SUPER", title: "Honigraum", action: { type: "ACCEPT", suggestionId: "s1" }, }), ], }), ); vi.mocked(acceptSuggestion).mockResolvedValue({} as never); renderCard(); await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" })); expect(acceptSuggestion).toHaveBeenCalledWith("s1", {}, { idempotencyKey: expect.any(String) }); }); it("dismisses a merged step with all its suggestion ids", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue( colonyStepsFixture({ steps: [stepFixture({ suggestionIds: ["s1", "s2"] })] }), ); vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 2 }); renderCard(); await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" })); expect(bulkDismissSuggestions).toHaveBeenCalledWith(["s1", "s2"], { idempotencyKey: expect.any(String), }); }); it("opens the cleanup sheet for a CLEANUP step", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue( colonyStepsFixture({ steps: [ stepFixture({ intent: "CLEANUP", title: "Volk aufräumen", suggestionIds: ["s9"], action: { type: "CLEANUP", suggestionId: "s9" }, }), ], }), ); renderCard(); await userEvent.click(await screen.findByRole("button", { name: "Aufräumen" })); expect(screen.getByText("Aufräumen-Sheet s9")).toBeInTheDocument(); }); it("renders nothing while there is nothing to say", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture({ steps: [] })); const { container } = renderCard(); await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalled()); expect(container).toBeEmptyDOMElement(); }); it("makes only the first step's action primary and the rest outline", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue( colonyStepsFixture({ steps: [stepFixture({ key: "suggestion:s1" }), stepFixture({ key: "suggestion:s2", title: "Zweite", suggestionIds: ["s2"] })], }), ); renderCard(); const links = await screen.findAllByRole("link", { name: "Erfassen" }); expect(links[0]).toHaveClass("bg-primary"); expect(links[1]).not.toHaveClass("bg-primary"); }); it("announces only a short status, not the whole list", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture()); const { container } = renderCard(); await screen.findByText("Durchsicht"); expect(screen.getByRole("status")).toHaveTextContent("1 nächster Schritt"); expect(container.querySelector("[aria-live]")).toBeNull(); }); it("moves focus to the heading after a decision, or to the page h1 when the section is gone", async () => { vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 1 }); vi.mocked(fetchColonyNextSteps) .mockResolvedValueOnce(colonyStepsFixture()) .mockResolvedValueOnce(colonyStepsFixture()); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(

V-01

, ); await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" })); await vi.waitFor(() => expect(screen.getByRole("heading", { name: "Als Nächstes" })).toHaveFocus(), ); vi.mocked(fetchColonyNextSteps).mockResolvedValue(colonyStepsFixture({ steps: [] })); await userEvent.click(screen.getByRole("button", { name: "Verwerfen" })); await vi.waitFor(() => expect(screen.getByRole("heading", { name: "V-01" })).toHaveFocus()); }); it("reports a partly failed undo as „n von m wieder geöffnet“", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue( colonyStepsFixture({ steps: [stepFixture({ suggestionIds: ["s1", "s2"] })] }), ); vi.mocked(bulkDismissSuggestions).mockResolvedValue({ dismissed: 2 }); vi.mocked(reopenSuggestion) .mockResolvedValueOnce({} as never) .mockRejectedValueOnce(new ApiError(409, "Der Vorschlag ist nicht mehr offen.", {})); renderCard(); await userEvent.click(await screen.findByRole("button", { name: "Verwerfen" })); await vi.waitFor(() => expect(toast).toHaveBeenCalledWith("Verworfen", expect.anything())); const options = vi.mocked(toast).mock.calls.find(([text]) => text === "Verworfen")![1] as unknown as { action: { onClick: () => void }; }; options.action.onClick(); await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith("1 von 2 wieder geöffnet")); }); it("shows a compact hint with retry for a server error and nothing when offline", async () => { vi.mocked(fetchColonyNextSteps).mockRejectedValue(new ApiError(500, "boom", {})); const first = renderCard(); expect(await screen.findByRole("status")).toHaveTextContent( "Die nächsten Schritte konnten nicht geladen werden.", ); expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeInTheDocument(); first.unmount(); vi.mocked(fetchColonyNextSteps).mockRejectedValue(new TypeError("Failed to fetch")); const second = renderCard(); await vi.waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalledTimes(2)); await vi.waitFor(() => expect(second.container).toBeEmptyDOMElement()); }); it("offline: decision buttons are off with „Nur mit Verbindung“ and the data says since when", async () => { vi.mocked(fetchColonyNextSteps).mockResolvedValue( colonyStepsFixture({ steps: [ stepFixture({ intent: "HONEY_SUPER", title: "Honigraum", action: { type: "ACCEPT", suggestionId: "s1" }, }), ], }), ); act(() => reportRequestOutcome("network-error")); renderCard(); expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Verwerfen" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Übernehmen" })).toHaveAttribute( "aria-describedby", screen.getByText("Nur mit Verbindung").id, ); expect(screen.getByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument(); }); it("keeps showing the cached steps when the reload fails at network level", async () => { vi.mocked(fetchColonyNextSteps).mockRejectedValue(new TypeError("Failed to fetch")); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); client.setQueryData( colonyNextStepsKey("c1"), colonyStepsFixture({ steps: [stepFixture({ title: "Aus dem Cache" })] }), ); render( , ); await waitFor(() => expect(fetchColonyNextSteps).toHaveBeenCalled()); expect(await screen.findByText("Aus dem Cache")).toBeInTheDocument(); // Online, but the reload failed: the cached steps say since when. expect(await screen.findByText(/^Stand: \d{2}\.\d{2}\. \d{2}:\d{2}$/)).toBeInTheDocument(); }); });