- 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]>
361 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|