Files
ApiCore/ApiCore-Frontend/src/components/autopilot/next-steps-card.spec.tsx
T
Nick SzaunigandClaude Opus 5.5 c2f71bcb03
Backend Image / build-and-push (push) Failing after 2m45s
Frontend Image / build-and-push (push) Successful in 5m52s
fix: T4a browser check findings
- 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]>
2026-10-14 18:26:09 +02:00

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