Files
ApiCore/ApiCore-Frontend/src/components/stock/stock-card.spec.tsx
T
Nick.RosenloecherandClaude Opus 5.5 208e9baaf0 feat(t5): Auswahlgruppe mit Haken, Karten-Variante, Plus/Minus (T5a A6)
- 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]>
2026-10-16 18:44:31 +02:00

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