feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)

- lib/hive: frontend port of the hive domain (ops, validator, facts,
  layout, follow-ups, describe texts, reason texts) running the backend
  fixtures, API client
- hive card on the colony page (same height as Stammdaten): SVG view,
  accessible outline tree, frame/part sheets with optimistic save,
  conflict sheet, history with past states, init from template or
  another colony, inspection draft mode
- follow-up toasts prefilling feeding and treatment forms (treatment
  end only with an open treatment)
- settings page "Beutensysteme" (hive-systems:manage)
- NumberStepper decimal steps, reactive motion level hook, holo tokens
- spec: owner design decision 2026-10-16 (glass hologram, focus mode)

The SVG view is the fallback; the 3D scene follows in L3.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-16 09:12:03 +02:00
1 parent 0091b3d28b
commit c608f5d405
87 files changed
+9493 -43

No files matched your search

@@ -0,0 +1,278 @@
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) => ({
...(await importOriginal<typeof import("@/lib/hive/api")>()),
fetchHive: vi.fn(),
fetchHiveSystems: vi.fn(),
fetchHiveTemplate: vi.fn(),
fetchHiveHistory: vi.fn(),
fetchHiveAt: vi.fn(),
postHiveChange: vi.fn(),
createHive: vi.fn(),
}));
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) }),
}));
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";
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("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 an absolutely placed child", async () => {
renderCard();
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
const scroll = screen.getByTestId("hive-outline-scroll");
expect(scroll).toHaveClass("sm:absolute", "sm:inset-0", "sm:overflow-y-auto");
expect(scroll.parentElement).toHaveClass("sm:relative", "sm:w-72");
});
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("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();
});
});
});