Files
ApiCore/ApiCore-Frontend/src/components/hive/part-sheet.spec.tsx
T
Nick SzaunigandClaude Opus 5.5 c608f5d405 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]>
2026-10-16 09:12:03 +02:00

333 lines
14 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRef, useState } from "react";
import { ApiError } from "@/lib/auth/api-client";
import { applyHiveOps } from "@/lib/hive/ops";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveOp, HiveState } from "@/lib/hive/types";
import { HiveOutline } from "./hive-outline";
import type { HiveOutlineHandle } from "./hive-outline";
import { PartSheet } from "./part-sheet";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const catalog = inlineCatalog();
const baseState = (): HiveState => ({
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b", "box", { frames: frames(10) }),
part("fe", "feeder", { state: { syrupLiters: 1, fondantKg: 0 } }),
part("l", "inner-lid", { state: { feedHole: "OPEN" } }),
part("r", "roof"),
],
});
interface HarnessProps {
partId: string;
initialState?: HiveState;
submit?: (ops: HiveOp[]) => Promise<unknown>;
startWithEvaporator?: boolean;
}
/** Applies submitted ops to its own state, like the optimistic cache does. */
function Harness({ partId, initialState = baseState(), submit, startWithEvaporator }: HarnessProps) {
const [state, setState] = useState(initialState);
const [open, setOpen] = useState(true);
const outlineRef = useRef<HiveOutlineHandle>(null);
const [store] = useState(createHiveViewStore);
const send = async (ops: HiveOp[]) => {
await submit?.(ops);
const result = applyHiveOps(state, ops, catalog);
if (result.ok) setState(result.state);
};
return (
<HiveViewProvider store={store}>
<HiveOutline
ref={outlineRef}
state={state}
catalog={catalog}
canEdit
announcement=""
onEditPart={() => {}}
onEditFrame={() => {}}
/>
<PartSheet
open={open}
onOpenChange={setOpen}
state={state}
catalog={catalog}
partId={partId}
submit={send}
outlineRef={outlineRef}
startWithEvaporator={startWithEvaporator}
/>
</HiveViewProvider>
);
}
const save = () => screen.getByRole("button", { name: "Speichern" });
const dialog = () => screen.getByRole("dialog");
describe("PartSheet", () => {
it("is titled with the part's display name", () => {
render(<Harness partId="fe" />);
expect(screen.getByRole("dialog", { name: "Futterzarge" })).toBeInTheDocument();
});
it("shows one control per state field", () => {
render(<Harness partId="fe" />);
expect(within(dialog()).getByLabelText("Flüssigfutter")).toHaveValue("1");
expect(within(dialog()).getByLabelText("Futterteig")).toHaveValue("0");
expect(within(dialog()).getAllByRole("textbox")).toHaveLength(2);
});
it("shows a choice field as a radio group", () => {
render(<Harness partId="l" />);
expect(screen.getByRole("radiogroup", { name: "Futterloch" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "offen" })).toBeChecked();
});
it("keeps Speichern disabled until something changed", () => {
render(<Harness partId="fe" />);
expect(save()).toBeDisabled();
});
it("saves only the changed level as setState", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="fe" submit={submit} />);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "3");
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }]);
});
it("takes a German decimal comma", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="fe" submit={submit} />);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "2,5");
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "fe", key: "syrupLiters", value: 2.5 }]);
});
it("blocks saving a level off the step grid and says so", async () => {
const user = userEvent.setup();
render(<Harness partId="fe" />);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "2,3");
expect(screen.getByText("Nur Schritte von 0,5.")).toBeInTheDocument();
expect(save()).toBeDisabled();
});
it("blocks saving an empty level", async () => {
const user = userEvent.setup();
render(<Harness partId="fe" />);
await user.clear(within(dialog()).getByLabelText("Flüssigfutter"));
expect(screen.getByText("Bitte einen Wert angeben.")).toBeInTheDocument();
expect(save()).toBeDisabled();
});
it("keeps the level at its maximum when more is typed", async () => {
const user = userEvent.setup();
render(<Harness partId="fe" />);
const field = within(dialog()).getByLabelText("Flüssigfutter");
await user.clear(field);
await user.type(field, "9");
expect(field).toHaveValue("3");
expect(save()).toBeEnabled();
});
it("saves a chosen option of a choice field", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="l" submit={submit} />);
await user.click(screen.getByRole("radio", { name: "zu" }));
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "l", key: "feedHole", value: "CLOSED" }]);
});
it("shows an error inline and stays open when saving fails", async () => {
const submit = vi.fn().mockRejectedValue(new ApiError(500, "kaputt", null));
const user = userEvent.setup();
render(<Harness partId="l" submit={submit} />);
await user.click(screen.getByRole("radio", { name: "zu" }));
await user.click(save());
expect(await screen.findByRole("alert")).toHaveTextContent("Speichern fehlgeschlagen");
expect(dialog()).toBeInTheDocument();
});
it("closes on a conflict so the conflict sheet can take over", async () => {
const submit = vi.fn().mockRejectedValue(
new ApiError(409, "Konflikt", { code: "HIVE_CONFLICT", opIndex: 0, reason: "x", current: {} }),
);
const user = userEvent.setup();
render(<Harness partId="l" submit={submit} />);
await user.click(screen.getByRole("radio", { name: "zu" }));
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("returns focus to the edited part, not to the body", async () => {
const user = userEvent.setup();
render(<Harness partId="l" />);
await user.click(screen.getByRole("radio", { name: "zu" }));
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Holzdeckel/ })).toHaveFocus());
expect(document.body).not.toHaveFocus();
});
describe("floor", () => {
it("offers Bausperre drin / draußen and inserts the active floor insert type", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="f" submit={submit} />);
expect(screen.getByRole("radiogroup", { name: "Bausperre" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "draußen" })).toBeChecked();
await user.click(screen.getByRole("radio", { name: "drin" }));
await user.click(save());
expect(submit).toHaveBeenCalledWith([
{
op: "addInsert",
hostId: "f",
part: { id: expect.any(String), partTypeId: "floor-insert", state: {} },
},
]);
});
it("removes an existing Bausperre", async () => {
const withInsert = baseState();
withInsert.stack[0] = part("f", "floor", { inserts: [part("fi", "floor-insert")] });
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="f" initialState={withInsert} submit={submit} />);
expect(screen.getByRole("radio", { name: "drin" })).toBeChecked();
await user.click(screen.getByRole("radio", { name: "draußen" }));
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "fi" }]);
});
});
describe("box", () => {
it("inserts an evaporator with the chosen acid level", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="b" submit={submit} />);
expect(screen.queryByLabelText("Säure")).toBeNull();
await user.click(screen.getByRole("radio", { name: "Verdunster einsetzen" }));
const acid = within(dialog()).getByLabelText("Säure");
await user.clear(acid);
await user.type(acid, "250");
await user.click(save());
expect(submit).toHaveBeenCalledWith([
{
op: "addInsert",
hostId: "b",
part: { id: expect.any(String), partTypeId: "evaporator", state: { acidMl: 250 } },
},
]);
});
it("blocks saving an evaporator whose acid is off the step grid", async () => {
const user = userEvent.setup();
render(<Harness partId="b" />);
await user.click(screen.getByRole("radio", { name: "Verdunster einsetzen" }));
const acid = within(dialog()).getByLabelText("Säure");
await user.clear(acid);
await user.type(acid, "255");
expect(save()).toBeDisabled();
});
it("preselects the evaporator when opened from the outline action", () => {
render(<Harness partId="b" startWithEvaporator />);
expect(screen.getByRole("radio", { name: "Verdunster einsetzen" })).toBeChecked();
expect(within(dialog()).getByLabelText("Säure")).toBeInTheDocument();
});
it("offers no insert when the box already has an evaporator", () => {
const withEvaporator = baseState();
withEvaporator.stack[1] = part("b", "box", {
frames: frames(10),
inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })],
});
render(<Harness partId="b" initialState={withEvaporator} />);
expect(screen.queryByRole("radio", { name: "Verdunster einsetzen" })).toBeNull();
});
it("moves focus to the new evaporator item after inserting", async () => {
const user = userEvent.setup();
render(<Harness partId="b" />);
await user.click(screen.getByRole("radio", { name: "Verdunster einsetzen" }));
const acid = within(dialog()).getByLabelText("Säure");
await user.clear(acid);
await user.type(acid, "100");
await user.click(save());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Verdunster/ })).toHaveFocus());
expect(document.body).not.toHaveFocus();
});
});
describe("evaporator", () => {
const withEvaporator = (): HiveState => {
const s = baseState();
s.stack[1] = part("b", "box", {
frames: frames(10),
inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })],
});
return s;
};
it("edits the acid level", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="ev" initialState={withEvaporator()} submit={submit} />);
const acid = within(dialog()).getByLabelText("Säure");
await user.clear(acid);
await user.type(acid, "200");
await user.click(save());
expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "ev", key: "acidMl", value: 200 }]);
});
it("asks before removing, then removes and focuses the box", async () => {
const submit = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<Harness partId="ev" initialState={withEvaporator()} submit={submit} />);
await user.click(screen.getByRole("button", { name: "Verdunster entfernen" }));
expect(submit).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Ja, entfernen" }));
expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "ev" }]);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
await waitFor(() => expect(screen.getByRole("treeitem", { name: /Zarge/ })).toHaveFocus());
expect(document.body).not.toHaveFocus();
});
it("moves focus into the confirmation, labels it as a group and returns focus on cancel", async () => {
const user = userEvent.setup();
render(<Harness partId="ev" initialState={withEvaporator()} submit={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Verdunster entfernen" }));
const group = screen.getByRole("group", { name: "Verdunster wirklich entfernen?" });
expect(within(group).getByText("Verdunster wirklich entfernen?")).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toHaveFocus();
});
it("keeps the evaporator when the confirmation is cancelled", async () => {
const submit = vi.fn();
const user = userEvent.setup();
render(<Harness partId="ev" initialState={withEvaporator()} submit={submit} />);
await user.click(screen.getByRole("button", { name: "Verdunster entfernen" }));
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
expect(submit).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Verdunster entfernen" })).toBeInTheDocument();
});
});
});