Files
ApiCore/ApiCore-Frontend/src/components/hive/hive-card.spec.tsx
T
Nick.RosenloecherandClaude Opus 5.5 ce68729233 feat(t5): Fokusring für alle, 44-px-Ziele bei Touch, 16-px-Schrift (T5a A5)
- globals.css: eine Basisregel :where(:focus-visible) mit --focus-width
  und --focus-offset (Sonne 3 px), Firefox-:-moz-focusring gleichgezogen;
  --text-xs 0.8125rem.
- Fokus-Codemod (scripts/codemod-t5-focus.mjs): eigene Ringe und
  outline-none raus, sm:h-/sm:size- zu pointer-fine:, Label-Zeilen mit
  focus-ring-within; Skript-Fokus (Lücken-Liste, „Gespeichert“) bleibt.
- Button/Input 44 px, kompakt nur mit Maus; Icon-Knöpfe mit unsichtbarer
  44-px-Trefferfläche bei Touch, Nachbarn mit genug Abstand.
- 16 px in Feldern, Labels, Auswahl, Leerzuständen, Listentiteln.
- .holo-stage auf allen Beute-Bühnen, .stage-card holt den Seitenring.
- Baukasten-Wächter (src/styles/baukasten-guard.spec.ts) mit den Regeln
  aus A3–A5, Ausnahmen mit Grund; Kalender-Auswahl als Inset-Ring.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 18:06:20 +02:00

361 lines
16 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 { ApiError } from "@/lib/auth/api-client";
import {
fetchHive,
fetchHiveAt,
fetchHiveHistory,
fetchHiveSystems,
fetchHiveTemplate,
postHiveChange,
} from "@/lib/hive/api";
import { templateState } from "@/lib/hive/test-utils";
import type { HiveOp } from "@/lib/hive/types";
import { HiveCard } from "./hive-card";
import { colonyFixture, historyItem, sampleState, setupView, systemDto } from "./test-fixtures";
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("sonner", () => ({ toast: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("@/lib/hive/api", async (importOriginal) => {
const fetchHiveSystems = vi.fn();
return {
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems,
// The offline-aware variant answers like the plain one here (no offline packs in this spec).
fetchHiveSystemsWithFallback: vi.fn(() => fetchHiveSystems()),
fetchHiveTemplate: vi.fn(),
fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(),
postHiveChange: vi.fn(),
createHive: vi.fn(),
};
});
const pendingHive = vi.hoisted(() => ({ ops: [] as unknown[], count: 0 }));
vi.mock("@/components/hive/use-pending-hive", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/hive/use-pending-hive")>()),
usePendingHiveOps: () => pendingHive.ops,
usePendingHiveCount: () => pendingHive.count,
}));
let permissions: string[] = [];
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
useCurrentUserId: () => null,
}));
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/offline/connectivity")>()),
useConnectivity: () => connectivity.value,
}));
function renderCard(props: Partial<React.ComponentProps<typeof HiveCard>> = {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<HiveCard colonyId="colony-1" colony={colonyFixture()} {...props} />
</QueryClientProvider>,
);
}
const FRAME_EDIT_OPS: HiveOp[] = [{ op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null }];
/** Opens Zarge 1 in the outline, picks frame 5 and chooses „Brutwabe“. */
async function editFrameFive() {
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
const dialog = await screen.findByRole("dialog", { name: "Rähmchen 5" });
await userEvent.click(within(dialog).getByRole("radio", { name: "Brutwabe" }));
await userEvent.click(within(dialog).getByRole("button", { name: "Speichern" }));
}
describe("HiveCard", () => {
beforeEach(() => {
permissions = ["colonies:read", "colonies:update"];
connectivity.value = "online";
pendingHive.ops = [];
pendingHive.count = 0;
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
vi.mocked(fetchHiveHistory)
.mockReset()
.mockResolvedValue({ items: [historyItem()], nextCursor: null });
vi.mocked(fetchHiveAt).mockReset();
vi.mocked(postHiveChange).mockReset();
});
it("shows the model, the facts line and where the state comes from", async () => {
renderCard();
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
expect(await screen.findByText("Stand: 12.10. aus Durchsicht")).toBeInTheDocument();
});
it("is the right column of the first row and as tall as the card beside it", async () => {
const { container } = renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
const root = container.querySelector("[data-slot=card]")!;
expect(root).toHaveClass("lg:col-span-2", "min-h-80", "lg:h-auto");
expect(root.className).toContain("h-[min(55svh,520px)]");
});
it("offers the hive setup instead of a model when the colony has none", async () => {
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
renderCard();
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeInTheDocument();
});
it("shows readers only „Noch keine Beute angelegt“", async () => {
permissions = ["colonies:read"];
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
renderCard();
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
});
it("explains a failed load, keeps the card and retries on request", async () => {
vi.mocked(fetchHive).mockRejectedValueOnce(new ApiError(500, "Fehler", {}));
renderCard();
expect(await screen.findByRole("alert")).toHaveTextContent("Beute konnte nicht geladen werden.");
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
expect(fetchHive).toHaveBeenCalledTimes(2);
});
describe("Umbauen", () => {
it("links to the editor route", async () => {
renderCard();
expect(await screen.findByRole("link", { name: "Umbauen" })).toHaveAttribute("href", "/colonies/colony-1/hive");
});
it("is hidden without colonies:update", async () => {
permissions = ["colonies:read"];
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Umbauen" })).not.toBeInTheDocument();
});
it("is hidden for a colony that no longer lives", async () => {
renderCard({ colony: colonyFixture({ status: "DISSOLVED" }) });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
});
it("is disabled offline and says why", async () => {
connectivity.value = "offline";
renderCard();
expect(await screen.findByText("Umbauen geht nur mit Netz")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Umbauen" })).toBeDisabled();
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
});
});
it("shows changes that wait for network on top of the loaded hive, with a note", async () => {
pendingHive.ops = FRAME_EDIT_OPS;
pendingHive.count = 1;
renderCard();
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
expect(screen.getByText("1 Änderung wartet auf Netz")).toBeInTheDocument();
});
it("the stage is a .holo-stage and the outline panel on it takes the page focus ring back", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
expect(screen.getByRole("img", { name: /Beute mit/ }).closest(".holo-stage")).not.toBeNull();
expect(toggle.closest(".holo-stage")).not.toBeNull();
await userEvent.click(toggle);
const panel = document.getElementById(toggle.getAttribute("aria-controls")!)!;
expect(panel).toHaveClass("stage-card");
});
it("the loading stage is a .holo-stage too", () => {
vi.mocked(fetchHive).mockReturnValue(new Promise(() => {}));
renderCard();
expect(screen.getByRole("status", { name: "Beute wird geladen" })).toHaveClass("holo-stage");
});
it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
expect(screen.getByRole("tree", { name: "Aufbau der Beute" })).toBeInTheDocument();
const wrapper = document.getElementById(toggle.getAttribute("aria-controls")!)!;
expect(wrapper).toHaveClass("sr-only");
expect(toggle).toHaveAttribute("aria-pressed", "false");
await userEvent.click(toggle);
expect(wrapper).not.toHaveClass("sr-only");
expect(toggle).toHaveAttribute("aria-pressed", "true");
});
it("keeps the outline from growing the card: it scrolls in a panel placed over the stage", async () => {
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
const scroll = screen.getByTestId("hive-outline-scroll");
expect(scroll).toHaveClass("overflow-y-auto");
// The panel fills the space above the button bar inside an absolutely placed column,
// so the stage (and the row) never grows and the buttons stay reachable.
const column = scroll.parentElement!.parentElement!;
expect(column).toHaveClass("order-1", "min-h-0", "flex-1");
expect(column.parentElement).toHaveClass("absolute", "inset-0");
const buttons = screen.getByRole("button", { name: "Aufbau als Liste" }).closest(".order-2");
expect(buttons?.parentElement).toBe(column.parentElement);
});
it("shows title and facts as a frosted overlay and the actions as glass buttons on the stage", async () => {
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("Beute")).toBeInTheDocument();
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
const list = screen.getByRole("button", { name: "Aufbau als Liste" });
expect(list).toHaveTextContent("Liste");
expect(screen.getByRole("button", { name: "Verlauf" })).toHaveClass("backdrop-blur-md");
});
it("points „Aufbau als Liste“ at the outline", async () => {
renderCard();
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
const target = document.getElementById(toggle.getAttribute("aria-controls")!);
expect(target).toContainElement(screen.getByRole("tree"));
});
describe("editing", () => {
it("saves a frame change from the outline as a CARD change", async () => {
vi.mocked(postHiveChange).mockResolvedValue(setupView());
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
await editFrameFive();
expect(postHiveChange).toHaveBeenCalledWith(
"colony-1",
{ occurredOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), source: "CARD", ops: FRAME_EDIT_OPS },
expect.any(String),
);
});
it("does not open a frame sheet for a reader", async () => {
permissions = ["colonies:read"];
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("in inspection mode keeps a draft and reports the ops instead of saving", async () => {
const onOpsChange = vi.fn();
renderCard({ mode: "inspection", onOpsChange });
await screen.findByRole("img", { name: /Beute mit/ });
await editFrameFive();
expect(onOpsChange).toHaveBeenCalledWith(FRAME_EDIT_OPS);
expect(postHiveChange).not.toHaveBeenCalled();
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
});
it("seeds the inspection draft from initialOps (after a remount)", async () => {
const onOpsChange = vi.fn();
renderCard({ mode: "inspection", onOpsChange, initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
// The outline shows the seeded draft, and seeding is not reported as a new edit.
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
expect(screen.getByRole("treeitem", { name: /R5 · Brutwabe/ })).toBeInTheDocument();
expect(onOpsChange).not.toHaveBeenCalled();
});
it("ignores initialOps that do not apply to the loaded hive, and in view mode", async () => {
const broken: HiveOp[] = [{ op: "setFrame", boxId: "gone", slot: 5, type: "BROOD", name: null }];
const onOpsChange = vi.fn();
const first = renderCard({ mode: "inspection", initialOps: broken, onOpsChange });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
// The form learns that its stored ops were dropped, so its list matches the card.
expect(onOpsChange).toHaveBeenCalledWith([]);
first.unmount();
renderCard({ initialOps: FRAME_EDIT_OPS });
await screen.findByRole("img", { name: /Beute mit/ });
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
});
it("opens the conflict sheet when the hive changed meanwhile", async () => {
vi.mocked(postHiveChange).mockRejectedValue(
new ApiError(409, "Konflikt", {
code: "HIVE_CONFLICT",
opIndex: 0,
reason: "x",
current: { version: 9, state: sampleState() },
}),
);
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
await editFrameFive();
expect(await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
});
});
describe("Verlauf", () => {
it("shows the state of a past day read-only and returns to today", async () => {
const past = { ...sampleState(), stack: sampleState().stack.slice(0, 2) };
vi.mocked(fetchHiveAt).mockResolvedValue({
version: 2,
state: past,
facts: {} as never,
occurredOn: "2026-08-12",
});
vi.mocked(fetchHiveHistory).mockResolvedValue({
items: [historyItem({ occurredOn: "2026-08-12", createdAt: "2026-08-12T10:00:00.000Z" })],
nextCursor: null,
});
renderCard();
await screen.findByRole("img", { name: /Beute mit/ });
await userEvent.click(screen.getByRole("button", { name: "Verlauf" }));
const dialog = await screen.findByRole("dialog", { name: "Verlauf" });
await userEvent.click(await within(dialog).findByRole("button", { name: /12\.08\./ }));
expect(await screen.findByText(/Stand vom 12\.08\./)).toBeInTheDocument();
expect(screen.getByText("1 Zarge · 3 Brutwaben · Bausperre drin")).toBeInTheDocument();
// Read-only: a frame cannot be edited while a past state is shown.
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge/ }));
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Zurück zu heute" }));
expect(screen.queryByText(/Stand vom/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Verlauf" })).toHaveFocus();
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
});
});
});