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