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