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:
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user