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()), 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()), usePendingHiveOps: () => pendingHive.ops, usePendingHiveCount: () => pendingHive.count, })); let permissions: string[] = []; vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({ ...(await importOriginal()), 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()), useConnectivity: () => connectivity.value, })); function renderCard(props: Partial> = {}) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( , ); } 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(); }); }); });