- Withdrawal wording from one backend helper everywhere ("bis
einschließlich TT.MM. – ab TT.MM. wieder erntefähig", year when it
crosses New Year); withdrawal-status returns lastBlockedDay and the
ready text, the frontend no longer computes dates
- Calendar events name colony or apiary, withdrawal events carry
product and detail; compact withdrawal rows on mobile
- One bottling warning per colony and treatment; pre-save hint
- Offline: online-only actions disabled with a reason, cached data
shows its timestamp, open sheets block submit offline
- Mobile: no horizontal scroll at 360 px, touch targets >= 44 px,
toasts and the add button no longer cover content
- Cleanup sheet without destructive preselection; natural sort of
colony codes; collapsible automation rule cards; varroa list newest
first with chart axes; text fixes
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
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<typeof import("@/lib/autopilot/api")>()),
|
|
fetchColonyNextSteps: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/suggestions/api", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/lib/suggestions/api")>()),
|
|
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 ? <p>Aufräumen-Sheet {suggestionId}</p> : 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(
|
|
<QueryClientProvider client={client}>
|
|
<NextStepsCard colonyId="c1" />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
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(
|
|
<QueryClientProvider client={client}>
|
|
<h1>V-01</h1>
|
|
<NextStepsCard colonyId="c1" />
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
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(
|
|
<QueryClientProvider client={client}>
|
|
<NextStepsCard colonyId="c1" />
|
|
</QueryClientProvider>,
|
|
);
|
|
|
|
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();
|
|
});
|
|
});
|