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; startWithEvaporator?: boolean; startWithFrameFeeder?: boolean; } /** Applies submitted ops to its own state, like the optimistic cache does. */ function Harness({ partId, initialState = baseState(), submit, startWithEvaporator, startWithFrameFeeder }: HarnessProps) { const [state, setState] = useState(initialState); const [open, setOpen] = useState(true); const outlineRef = useRef(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 ( {}} onEditFrame={() => {}} /> ); } const save = () => screen.getByRole("button", { name: "Speichern" }); const dialog = () => screen.getByRole("dialog"); describe("PartSheet", () => { it("is titled with the part's display name", () => { render(); expect(screen.getByRole("dialog", { name: "Futterzarge" })).toBeInTheDocument(); }); it("shows one control per state field", () => { render(); 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(); expect(screen.getByRole("radiogroup", { name: "Futterloch" })).toBeInTheDocument(); expect(screen.getByRole("radio", { name: "offen" })).toBeChecked(); }); it("keeps Speichern disabled until something changed", () => { render(); expect(save()).toBeDisabled(); }); it("does not let its submit bubble into an enclosing form", async () => { const outer = vi.fn((event: { preventDefault: () => void }) => event.preventDefault()); const user = userEvent.setup(); render(
, ); const field = within(dialog()).getByLabelText("Flüssigfutter"); await user.clear(field); await user.type(field, "3"); await user.click(save()); expect(outer).not.toHaveBeenCalled(); }); it("saves only the changed level as setState", async () => { const submit = vi.fn().mockResolvedValue(undefined); const user = userEvent.setup(); render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(screen.queryByRole("radio", { name: "Verdunster einsetzen" })).toBeNull(); }); it("moves focus to the new evaporator item after inserting", async () => { const user = userEvent.setup(); render(); 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(); 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(); 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(); 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(); 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(); }); }); describe("frame feeder", () => { const withPocket = (): HiveState => { const s = baseState(); s.stack[1] = part("b", "box", { frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)), inserts: [part("ff", "frame-feeder", { slot: 1, state: { syrupLiters: 0.5 } })], }); return s; }; it("inserts a frame feeder: setFrames clears the covered slots, addInsert hangs it", async () => { const submit = vi.fn().mockResolvedValue(undefined); const user = userEvent.setup(); render(); await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" })); await user.click(screen.getByRole("radio", { name: "Plätze 3–4" })); expect(screen.getByText(/werden zu „leer“/)).toBeInTheDocument(); const syrup = within(dialog()).getByLabelText("Flüssigfutter"); await user.clear(syrup); await user.type(syrup, "1,5"); await user.click(save()); const expectedFrames = frames(10).map((f) => (f.slot === 3 || f.slot === 4 ? { ...f, type: "NONE" } : f)); expect(submit).toHaveBeenCalledWith([ { op: "setFrames", boxId: "b", frames: expectedFrames }, { op: "addInsert", hostId: "b", part: { id: expect.any(String), partTypeId: "frame-feeder", state: { syrupLiters: 1.5 }, slot: 3 }, }, ]); }); it("skips setFrames when the covered slots are already empty", async () => { const submit = vi.fn().mockResolvedValue(undefined); const user = userEvent.setup(); const state = baseState(); state.stack[1] = part("b", "box", { frames: frames(10).map((f) => (f.slot <= 2 ? { ...f, type: "NONE" as const } : f)), }); render(); await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" })); expect(screen.queryByText(/werden zu „leer“/)).toBeNull(); await user.click(save()); expect(submit).toHaveBeenCalledWith([ { op: "addInsert", hostId: "b", part: expect.objectContaining({ partTypeId: "frame-feeder", slot: 1 }) }, ]); }); it("offers only positions where the slots are still free", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" })); expect(screen.queryByRole("radio", { name: "Plätze 1–2" })).toBeNull(); expect(screen.queryByRole("radio", { name: "Plätze 2–3" })).toBeNull(); expect(screen.getByRole("radio", { name: "Plätze 3–4" })).toBeInTheDocument(); }); it("preselects the frame feeder when opened from the outline action", () => { render(); expect(screen.getByRole("radio", { name: "Futtertasche einsetzen" })).toBeChecked(); }); it("moves focus to the new pocket item after inserting", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("radio", { name: "Futtertasche einsetzen" })); await user.click(save()); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); await waitFor(() => expect(screen.getByRole("treeitem", { name: /^F1–2 · Futtertasche/ })).toHaveFocus()); }); it("edits the pocket level with a stepper", async () => { const submit = vi.fn().mockResolvedValue(undefined); const user = userEvent.setup(); render(); expect(screen.getByRole("dialog", { name: "Futtertasche" })).toBeInTheDocument(); await user.click(within(dialog()).getByRole("button", { name: /Flüssigfutter erhöhen/ })); await user.click(save()); expect(submit).toHaveBeenCalledWith([{ op: "setState", id: "ff", key: "syrupLiters", value: 0.75 }]); }); it("asks before removing the pocket, then removes it and focuses the box", async () => { const submit = vi.fn().mockResolvedValue(undefined); const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "Futtertasche entfernen" })); expect(submit).not.toHaveBeenCalled(); expect(screen.getByRole("group", { name: "Futtertasche wirklich entfernen?" })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Ja, entfernen" })); expect(submit).toHaveBeenCalledWith([{ op: "removePart", id: "ff" }]); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); await waitFor(() => expect(screen.getByRole("treeitem", { name: /Zarge/ })).toHaveFocus()); }); }); });