Files
ApiCore/ApiCore-Frontend/src/components/hive/part-sheet.spec.tsx
T
Nick SzaunigandClaude Opus 5.5 1a57de8573 feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes:
- Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit
  Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben
- Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit
  Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts)
- Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe,
  Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox

Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss
beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand-
Änderung, Konfliktblatt.

Beute L5:
- Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht,
  Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst
  speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach
  Rückgängig, Folgevorschläge beim Speichern
- Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes
  (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt
  wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“

T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung
standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite
„Sprache & Datenschutz“; axe-core als exakte devDependency.

Backend: NotificationsService liest Empfänger und schreibt in einer
Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers
führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen
Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft.

Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint,
typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978).
Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion.

Refs #19 #20 #21 #22 #27 #32

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:04 +02:00

445 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
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<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}
startWithFrameFeeder={startWithFrameFeeder}
/>
</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("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(
<form onSubmit={outer}>
<Harness partId="fe" submit={vi.fn().mockResolvedValue(undefined)} />
</form>,
);
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(<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();
});
});
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(<Harness partId="b" submit={submit} />);
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(<Harness partId="b" initialState={state} submit={submit} />);
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(<Harness partId="b" initialState={withPocket()} />);
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(<Harness partId="b" startWithFrameFeeder />);
expect(screen.getByRole("radio", { name: "Futtertasche einsetzen" })).toBeChecked();
});
it("moves focus to the new pocket item after inserting", async () => {
const user = userEvent.setup();
render(<Harness partId="b" />);
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(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
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(<Harness partId="ff" initialState={withPocket()} submit={submit} />);
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());
});
});
});