- ChoiceGroup nach Spec 10.2: gewählt mit Haken und 2-px-Tintenrahmen, „Nicht geprüft“ gestrichelt; neue Variante appearance="cards" mit Icon und Beschreibung (Winter-Ergebnis, Darstellung, Behördenmeldung). - Eigenbau-Radios in Behördenmeldung, Phänologie-Bericht und Zeilen-Ausrichtung durch ChoiceGroup ersetzt. - Button pressed → aria-pressed (Fett/Kursiv, „Aufbau als Liste“); Druck-Versatz nur mit motion-full. - NumberStepper (ganze Schritte) für Winter-Waben, Milbenzahl (neu und bearbeiten), Weiselzellen, Entnahme/Zählbestand, Startposition; Entnahme nur 1 bis Restmenge, Startposition höchstens 1000. - Baukasten-Wächter: Radios nur in ChoiceGroup, aria-pressed nur in Button und Kalender, auch in Objektform und .ts-Dateien. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { render, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { StockCard } from "./stock-card";
|
|
import { correctStock, fetchStock, withdrawStock } from "@/lib/stock/api";
|
|
import type { BatchStock, StockBatchPath } from "@/lib/stock/types";
|
|
|
|
vi.mock("@/lib/stock/api", () => ({
|
|
fetchStock: vi.fn(),
|
|
withdrawStock: vi.fn(),
|
|
correctStock: vi.fn(),
|
|
}));
|
|
|
|
function renderCard(canBook = true, unit = "Gläser", path: StockBatchPath = "bottling-batches") {
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
return render(
|
|
<QueryClientProvider client={client}>
|
|
<StockCard
|
|
path={path}
|
|
id="b1"
|
|
initialLabel="Abgefüllt"
|
|
unit={unit}
|
|
canBook={canBook}
|
|
/>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
function stock(remaining: number): BatchStock {
|
|
return {
|
|
summary: {
|
|
initial: 120,
|
|
soldInShop: 30,
|
|
withdrawn: 10,
|
|
corrected: 0,
|
|
consumed: 0,
|
|
remaining,
|
|
},
|
|
movements: [
|
|
{
|
|
id: "m1",
|
|
type: "SHOP_SALE",
|
|
quantity: -30,
|
|
reason: null,
|
|
notes: null,
|
|
apiKeyId: "k1",
|
|
createdAt: "2026-10-01T10:00:00.000Z",
|
|
user: null,
|
|
shopRequest: { externalId: "x", kind: "SALE" },
|
|
},
|
|
{
|
|
id: "m2",
|
|
type: "WITHDRAWAL",
|
|
quantity: -10,
|
|
reason: "HOFLADEN",
|
|
notes: null,
|
|
apiKeyId: null,
|
|
createdAt: "2026-10-01T11:00:00.000Z",
|
|
user: { id: "u1", name: "Nick" },
|
|
shopRequest: null,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
describe("StockCard", () => {
|
|
beforeEach(() => vi.mocked(fetchStock).mockReset());
|
|
|
|
it("separates bottled, sold, withdrawn and remaining", async () => {
|
|
vi.mocked(fetchStock).mockResolvedValue(stock(80));
|
|
renderCard();
|
|
|
|
expect(await screen.findByText("Abgefüllt")).toBeInTheDocument();
|
|
expect(screen.getByText("120")).toBeInTheDocument();
|
|
expect(screen.getByText("Webshop verkauft")).toBeInTheDocument();
|
|
expect(screen.getByText("80")).toBeInTheDocument();
|
|
expect(screen.queryByText("Prüfen")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("lists shop sales as coming from the shop and withdrawals with their reason", async () => {
|
|
vi.mocked(fetchStock).mockResolvedValue(stock(80));
|
|
renderCard();
|
|
|
|
expect(await screen.findByText("Webshop-Verkauf")).toBeInTheDocument();
|
|
expect(screen.getByText(/· Webshop$/)).toBeInTheDocument();
|
|
expect(screen.getByText("Entnahme · Hofladen")).toBeInTheDocument();
|
|
});
|
|
|
|
it("flags a negative remaining stock for review", async () => {
|
|
vi.mocked(fetchStock).mockResolvedValue(stock(-2));
|
|
renderCard();
|
|
|
|
expect(await screen.findByText("Prüfen")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the booking buttons only with permission", async () => {
|
|
vi.mocked(fetchStock).mockResolvedValue(stock(80));
|
|
renderCard(false);
|
|
|
|
await screen.findByText("Abgefüllt");
|
|
expect(
|
|
screen.queryByRole("button", { name: /Entnehmen/ }),
|
|
).not.toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole("button", { name: /Inventur/ }),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
it("stock card withdraw and counted stock use NumberStepper", async () => {
|
|
vi.mocked(fetchStock).mockResolvedValue(stock(80));
|
|
vi.mocked(withdrawStock).mockResolvedValue(undefined as never);
|
|
vi.mocked(correctStock).mockResolvedValue(undefined as never);
|
|
renderCard();
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
|
|
let dialog = await screen.findByRole("dialog");
|
|
const quantity = within(dialog).getByLabelText("Anzahl (Gläser)");
|
|
expect(quantity).toHaveValue("1");
|
|
expect(quantity).toHaveAttribute("inputmode", "numeric");
|
|
await userEvent.click(within(dialog).getByRole("button", { name: /Eins mehr/ }));
|
|
await userEvent.click(within(dialog).getByRole("button", { name: "Entnahme buchen" }));
|
|
expect(vi.mocked(withdrawStock).mock.calls[0][2]).toMatchObject({ quantity: 2 });
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: /Inventur korrigieren/ }));
|
|
dialog = await screen.findByRole("dialog");
|
|
const counted = within(dialog).getByLabelText("Gezählt (Gläser)");
|
|
expect(counted).toHaveValue("80");
|
|
await userEvent.click(within(dialog).getByRole("button", { name: /Eins weniger/ }));
|
|
expect(within(dialog).getByText(/Differenz: -1/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps a typed field for amounts in grams", async () => {
|
|
vi.mocked(fetchStock).mockResolvedValue(stock(500));
|
|
renderCard(true, "g", "raw-material-lots");
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
|
|
const dialog = await screen.findByRole("dialog");
|
|
expect(within(dialog).getByLabelText("Anzahl (g)")).toHaveAttribute("type", "number");
|
|
expect(within(dialog).queryByRole("button", { name: /mehr$/ })).toBeNull();
|
|
});
|
|
|
|
it("does not book an empty withdrawal", async () => {
|
|
vi.mocked(fetchStock).mockResolvedValue(stock(80));
|
|
renderCard();
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
|
|
const dialog = await screen.findByRole("dialog");
|
|
await userEvent.clear(within(dialog).getByLabelText("Anzahl (Gläser)"));
|
|
expect(within(dialog).getByRole("button", { name: "Entnahme buchen" })).toBeDisabled();
|
|
});
|
|
it("does not book a withdrawal of 0 or more than is left", async () => {
|
|
vi.mocked(fetchStock).mockResolvedValue(stock(80));
|
|
renderCard();
|
|
|
|
await userEvent.click(await screen.findByRole("button", { name: /Entnehmen/ }));
|
|
const dialog = await screen.findByRole("dialog");
|
|
const quantity = within(dialog).getByLabelText("Anzahl (Gläser)");
|
|
const book = within(dialog).getByRole("button", { name: "Entnahme buchen" });
|
|
await userEvent.clear(quantity);
|
|
await userEvent.type(quantity, "0");
|
|
expect(book).toBeDisabled();
|
|
await userEvent.clear(quantity);
|
|
await userEvent.type(quantity, "80");
|
|
expect(book).toBeEnabled();
|
|
});
|
|
});
|