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
@@ -24,7 +24,7 @@ export function ColonyTreatmentsCard({
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card id="behandlungen">
|
||||
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle>Behandlungen</CardTitle>
|
||||
<Button
|
||||
|
||||
@@ -17,8 +17,9 @@ vi.mock("@/lib/export/api", () => ({
|
||||
exportColonyRecordCardPdf: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("./colony-edit-card", () => ({ ColonyEditCard: () => null }));
|
||||
vi.mock("./colony-actions", () => ({ ColonyActions: () => null }));
|
||||
vi.mock("./colony-edit-card", () => ({ ColonyEditCard: () => <div data-testid="edit-card" /> }));
|
||||
vi.mock("./colony-actions", () => ({ ColonyActions: () => <div data-testid="actions" /> }));
|
||||
vi.mock("@/components/hive/hive-card", () => ({ HiveCard: () => <div data-testid="hive-card" /> }));
|
||||
vi.mock("./colony-history", () => ({ ColonyHistoryCard: () => null }));
|
||||
vi.mock("./colony-inspections", () => ({ ColonyInspectionsCard: () => null }));
|
||||
vi.mock("./colony-varroa", () => ({ ColonyVarroaCard: () => null }));
|
||||
@@ -159,3 +160,51 @@ describe("ColonyDetailPage – ?editTreatment", () => {
|
||||
expect(await screen.findByText("Behandlungen, Dialog für keine")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ColonyDetailPage – grid", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchColony).mockReset().mockResolvedValue({
|
||||
id: "colony-1",
|
||||
code: "V-01",
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
status: "ACTIVE",
|
||||
strength: null,
|
||||
occupiedFrames: null,
|
||||
} as Colony);
|
||||
});
|
||||
|
||||
it("puts Stammdaten and Beute in one grid row that stretches both to the same height", async () => {
|
||||
renderPage();
|
||||
|
||||
const edit = await screen.findByTestId("edit-card");
|
||||
const hive = screen.getByTestId("hive-card");
|
||||
expect(edit.parentElement).toBe(hive.parentElement);
|
||||
const row = edit.parentElement!;
|
||||
expect(row).toHaveClass("grid", "grid-cols-1", "gap-4", "lg:grid-cols-3");
|
||||
expect(row).not.toHaveClass("items-start");
|
||||
});
|
||||
|
||||
it("keeps the order Stammdaten, Beute, Aktionen, Karten", async () => {
|
||||
renderPage();
|
||||
|
||||
const nodes = [
|
||||
await screen.findByTestId("edit-card"),
|
||||
screen.getByTestId("hive-card"),
|
||||
screen.getByTestId("actions"),
|
||||
screen.getByText("Behandlungen, Dialog für keine"),
|
||||
];
|
||||
for (let i = 1; i < nodes.length; i += 1) {
|
||||
expect(nodes[i - 1].compareDocumentPosition(nodes[i]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("starts the second row (Aktionen, Karten) with items-start", async () => {
|
||||
renderPage();
|
||||
|
||||
const actions = await screen.findByTestId("actions");
|
||||
const secondRow = actions.closest(".grid")!;
|
||||
expect(secondRow).toHaveClass("lg:grid-cols-3", "items-start");
|
||||
expect(secondRow.contains(screen.getByText("Behandlungen, Dialog für keine"))).toBe(true);
|
||||
expect(secondRow.contains(screen.getByTestId("hive-card"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { fetchColony } from "@/lib/colonies/api";
|
||||
import { COLONY_STATUS_LABELS, COLONY_TYPE_LABELS, colonyStrengthText } from "@/lib/colonies/labels";
|
||||
import { exportColonyAnnualReportPdf, exportColonyRecordCardPdf } from "@/lib/export/api";
|
||||
import { HiveCard } from "@/components/hive/hive-card";
|
||||
import { NextStepsCard } from "@/components/autopilot/next-steps-card";
|
||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||
@@ -88,9 +89,13 @@ export default function ColonyDetailPage() {
|
||||
/>
|
||||
{strengthText ? <p className="text-sm text-muted-foreground">{strengthText}</p> : null}
|
||||
<NextStepsCard colonyId={colony.id} />
|
||||
{/* Row 1 has no items-start: the grid row stretches Stammdaten and Beute to one height (spec 9.2). */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<ColonyEditCard key={colony.id} colony={colony} />
|
||||
<HiveCard key={`hive-${colony.id}`} colonyId={colony.id} colony={colony} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-4 lg:col-span-1">
|
||||
<ColonyEditCard key={colony.id} colony={colony} />
|
||||
<ColonyActions colony={colony} />
|
||||
<div>
|
||||
<QrCodeDialog entityType="colonies" id={colony.id} label="QR-Code für die Beute" />
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialFeedingValues } from "./feeding-form-values";
|
||||
|
||||
const params = (query: string) => new URLSearchParams(query);
|
||||
|
||||
describe("initialFeedingValues prefill", () => {
|
||||
it("keeps the defaults without search params", () => {
|
||||
const values = initialFeedingValues("c1", params("colonyId=c1"));
|
||||
expect(values).toMatchObject({ colonyId: "c1", amount: "", unit: "l", feedType: "SUGAR_WATER" });
|
||||
});
|
||||
|
||||
it("reads date, amount, unit and feedType from the search params", () => {
|
||||
const values = initialFeedingValues(
|
||||
"c1",
|
||||
params("colonyId=c1&date=2026-10-12&amount=3&unit=l&feedType=FEED_PASTE"),
|
||||
);
|
||||
expect(values).toMatchObject({
|
||||
date: "2026-10-12",
|
||||
amount: "3",
|
||||
unit: "l",
|
||||
feedType: "FEED_PASTE",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a decimal amount", () => {
|
||||
expect(initialFeedingValues("c1", params("amount=2.5&unit=kg")).amount).toBe("2.5");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["date=2026-02-30", "date"],
|
||||
["date=morgen", "date"],
|
||||
["amount=abc", "amount"],
|
||||
["amount=-1", "amount"],
|
||||
["amount=0", "amount"],
|
||||
["amount=Infinity", "amount"],
|
||||
["unit=Eimer", "unit"],
|
||||
["feedType=HONEY", "feedType"],
|
||||
] as const)("ignores the invalid value in %s", (query, key) => {
|
||||
const defaults = initialFeedingValues("c1");
|
||||
const values = initialFeedingValues("c1", params(query));
|
||||
expect(values[key]).toBe(defaults[key]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import type { BulkCreateFeedingInput } from "@/lib/bulk/api";
|
||||
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
|
||||
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { prefillAmount, prefillDay, type ParamSource } from "@/lib/forms/prefill-params";
|
||||
import { FEED_TYPES } from "@/lib/feedings/labels";
|
||||
|
||||
/** Common units as one tap each; the API still receives a plain string. */
|
||||
export const FEEDING_UNIT_OPTIONS: { value: string; label: string }[] = [
|
||||
@@ -28,16 +30,22 @@ export interface FeedingFormValues {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export function initialFeedingValues(colonyId: string): FeedingFormValues {
|
||||
/**
|
||||
* Start values. Search params `date`, `amount`, `unit`, `feedType` (the hive
|
||||
* card's „Als Fütterung eintragen“ link) prefill the fields; invalid ones are ignored.
|
||||
*/
|
||||
export function initialFeedingValues(colonyId: string, params?: ParamSource): FeedingFormValues {
|
||||
const unit = params?.get("unit") ?? "";
|
||||
const feedType = params?.get("feedType") ?? "";
|
||||
return {
|
||||
colonyId,
|
||||
target: "single",
|
||||
bulkApiaryId: null,
|
||||
bulkColonyIds: [],
|
||||
date: todayLocalIsoDate(),
|
||||
feedType: "SUGAR_WATER",
|
||||
amount: "",
|
||||
unit: "l",
|
||||
date: (params && prefillDay(params.get("date"))) ?? todayLocalIsoDate(),
|
||||
feedType: FEED_TYPES.find((candidate) => candidate === feedType) ?? "SUGAR_WATER",
|
||||
amount: (params && prefillAmount(params.get("amount"))) ?? "",
|
||||
unit: FEEDING_UNIT_OPTIONS.some((option) => option.value === unit) ? unit : "l",
|
||||
product: "",
|
||||
batchNumber: "",
|
||||
notes: "",
|
||||
|
||||
@@ -9,11 +9,13 @@ import { createFeedingsBulk } from "@/lib/bulk/api";
|
||||
import { queueBulk } from "@/lib/offline/db";
|
||||
import { fetchApiaries } from "@/lib/apiaries/api";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||
|
||||
const push = vi.fn();
|
||||
const search = vi.hoisted(() => ({ query: "colonyId=colony-1" }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
useSearchParams: () => new URLSearchParams("colonyId=colony-1"),
|
||||
useSearchParams: () => new URLSearchParams(search.query),
|
||||
}));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/feedings/api", () => ({ createFeeding: vi.fn() }));
|
||||
@@ -45,6 +47,7 @@ function renderPage() {
|
||||
describe("NewFeedingPage", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
search.query = "colonyId=colony-1";
|
||||
push.mockReset();
|
||||
vi.mocked(fetchColonies).mockResolvedValue([
|
||||
{ id: "colony-1", code: "V-01", status: "ACTIVE" },
|
||||
@@ -57,6 +60,15 @@ describe("NewFeedingPage", () => {
|
||||
.mockResolvedValue([{ id: "apiary-1", name: "Waldrand" }] as never);
|
||||
});
|
||||
|
||||
it("prefills date, amount, unit and feed type from the hive card link", async () => {
|
||||
search.query = "colonyId=colony-1&date=2026-10-12&amount=3&unit=l&feedType=FEED_PASTE";
|
||||
renderPage();
|
||||
expect(await screen.findByLabelText(/^Menge/)).toHaveValue(3);
|
||||
expect(screen.getByLabelText(/^Datum/)).toHaveValue("2026-10-12");
|
||||
expect(screen.getByRole("radio", { name: "l" })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: FEED_TYPE_LABELS.FEED_PASTE })).toBeChecked();
|
||||
});
|
||||
|
||||
it("marks what is required and keeps the extra fields folded", () => {
|
||||
renderPage();
|
||||
// Volk, Datum, Futterart, Menge, Einheit
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function NewFeedingPage() {
|
||||
|
||||
|
||||
const [values, setValues] = useState<FeedingFormValues>(() =>
|
||||
initialFeedingValues(searchParams.get("colonyId") ?? ""),
|
||||
initialFeedingValues(searchParams.get("colonyId") ?? "", searchParams),
|
||||
);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
@@ -51,6 +51,12 @@ describe("nav-config", () => {
|
||||
expect(hrefs(["system:read"])).toContain("/settings/system");
|
||||
});
|
||||
|
||||
it("shows „Beutensysteme“ only with hive-systems:manage", () => {
|
||||
expect(hrefs([])).not.toContain("/settings/hive-systems");
|
||||
expect(hrefs(["colonies:read"])).not.toContain("/settings/hive-systems");
|
||||
expect(hrefs(["hive-systems:manage"])).toContain("/settings/hive-systems");
|
||||
});
|
||||
|
||||
it("hides items the user may not see", () => {
|
||||
expect(hrefs([])).not.toContain("/winter");
|
||||
expect(hrefs(["winter-checks:read"])).toContain("/winter");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Box,
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
Crown,
|
||||
@@ -152,6 +153,12 @@ export const ADMIN_GROUP: NavGroup = {
|
||||
"api-keys:manage",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Beutensysteme",
|
||||
href: "/settings/hive-systems",
|
||||
icon: Box,
|
||||
permission: "hive-systems:manage",
|
||||
},
|
||||
{
|
||||
label: "Backup",
|
||||
href: "/settings/backup",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import HiveSystemsPage from "./page";
|
||||
import { fetchHiveSystems } from "@/lib/hive/api";
|
||||
import { updateHivePartType } from "@/lib/hive/catalog-api";
|
||||
import type { HivePartTypeDto, HiveSystemDto } from "@/lib/hive/types";
|
||||
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@/lib/hive/api")>();
|
||||
return { ...original, fetchHiveSystems: vi.fn() };
|
||||
});
|
||||
vi.mock("@/lib/hive/catalog-api", () => ({
|
||||
createHiveSystem: vi.fn(),
|
||||
updateHiveSystem: vi.fn(),
|
||||
createHivePartType: vi.fn(),
|
||||
updateHivePartType: vi.fn(),
|
||||
}));
|
||||
let permissions: string[] = [];
|
||||
vi.mock("@/lib/auth/auth-context", () => ({
|
||||
useAuth: () => ({ user: { permissions } }),
|
||||
}));
|
||||
|
||||
const stamp = { createdAt: "2026-10-01T00:00:00.000Z", updatedAt: "2026-10-01T00:00:00.000Z" };
|
||||
|
||||
const feeder: HivePartTypeDto = {
|
||||
id: "pt1",
|
||||
systemId: "s1",
|
||||
kind: "FEEDER",
|
||||
name: "Futtertrog XL",
|
||||
articleRef: null,
|
||||
lengthMm: 500,
|
||||
widthMm: 480,
|
||||
heightMm: 200,
|
||||
frameSlots: null,
|
||||
boxDepth: null,
|
||||
stateSchema: [
|
||||
{ key: "syrup", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
|
||||
{ key: "paste", label: "Futterteig", kind: "level", unit: "kg", max: 2.5, step: 0.5 },
|
||||
],
|
||||
active: true,
|
||||
...stamp,
|
||||
};
|
||||
const box: HivePartTypeDto = {
|
||||
...feeder,
|
||||
id: "pt2",
|
||||
kind: "BOX",
|
||||
name: "Zarge DNM",
|
||||
heightMm: 240,
|
||||
frameSlots: 10,
|
||||
boxDepth: "FULL",
|
||||
stateSchema: [],
|
||||
active: false,
|
||||
};
|
||||
const dadant: HiveSystemDto = {
|
||||
id: "s1",
|
||||
name: "Dadant",
|
||||
frameWidthMm: 435,
|
||||
frameHeightMm: 300,
|
||||
footprintLMm: 500,
|
||||
footprintWMm: 480,
|
||||
template: { schemaVersion: 1, stack: [{}, {}, {}, {}, {}, {}] },
|
||||
active: true,
|
||||
partTypes: [feeder, box],
|
||||
...stamp,
|
||||
};
|
||||
const zander: HiveSystemDto = {
|
||||
...dadant,
|
||||
id: "s2",
|
||||
name: "Zander",
|
||||
template: null,
|
||||
active: false,
|
||||
partTypes: [],
|
||||
};
|
||||
|
||||
function renderPage() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<HiveSystemsPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("HiveSystemsPage", () => {
|
||||
beforeEach(() => {
|
||||
permissions = ["hive-systems:manage"];
|
||||
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([dadant, zander]);
|
||||
vi.mocked(updateHivePartType).mockReset().mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
it("shows the no-access state and loads nothing without the permission", () => {
|
||||
permissions = ["colonies:read"];
|
||||
renderPage();
|
||||
expect(screen.getByText(/keine Berechtigung/i)).toBeInTheDocument();
|
||||
expect(fetchHiveSystems).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists one card per system with chips, dimensions and template info", async () => {
|
||||
renderPage();
|
||||
const dadantCard = await screen.findByRole("region", { name: "Dadant" });
|
||||
expect(within(dadantCard).getByText("aktiv", { selector: "[data-system-chip]" })).toBeInTheDocument();
|
||||
expect(within(dadantCard).getByText(/435 × 300 mm/)).toBeInTheDocument();
|
||||
expect(within(dadantCard).getByText(/500 × 480 mm/)).toBeInTheDocument();
|
||||
expect(within(dadantCard).getByText("Vorlage: 6 Teile")).toBeInTheDocument();
|
||||
|
||||
const zanderCard = screen.getByRole("region", { name: "Zander" });
|
||||
expect(within(zanderCard).getByText("ausgeblendet", { selector: "[data-system-chip]" })).toBeInTheDocument();
|
||||
expect(within(zanderCard).getByText("Keine Vorlage")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("describes each part type: kind, size, slots and state fields", async () => {
|
||||
renderPage();
|
||||
const card = await screen.findByRole("region", { name: "Dadant" });
|
||||
const feederRow = within(card).getByRole("row", { name: /Futtertrog XL/ });
|
||||
expect(feederRow).toHaveTextContent("Futterzarge");
|
||||
expect(feederRow).toHaveTextContent("500 × 480 × 200 mm");
|
||||
expect(feederRow).toHaveTextContent("Flüssigfutter 0–3 l, Futterteig 0–2,5 kg");
|
||||
expect(feederRow).toHaveTextContent("aktiv");
|
||||
|
||||
const boxRow = within(card).getByRole("row", { name: /Zarge DNM/ });
|
||||
expect(boxRow).toHaveTextContent("10");
|
||||
expect(boxRow).toHaveTextContent("ausgeblendet");
|
||||
});
|
||||
|
||||
it("„Ausblenden“ sends active:false, „Einblenden“ active:true", async () => {
|
||||
renderPage();
|
||||
const card = await screen.findByRole("region", { name: "Dadant" });
|
||||
await userEvent.click(
|
||||
within(within(card).getByRole("row", { name: /Futtertrog XL/ })).getByRole("button", {
|
||||
name: /Ausblenden/,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt1", { active: false }));
|
||||
|
||||
await userEvent.click(
|
||||
within(within(card).getByRole("row", { name: /Zarge DNM/ })).getByRole("button", {
|
||||
name: /Einblenden/,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt2", { active: true }));
|
||||
});
|
||||
|
||||
it("has no delete action anywhere", async () => {
|
||||
renderPage();
|
||||
await screen.findByRole("region", { name: "Dadant" });
|
||||
expect(screen.queryByRole("button", { name: /löschen|entfernen/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the dialog to add a part and to edit one", async () => {
|
||||
renderPage();
|
||||
const card = await screen.findByRole("region", { name: "Dadant" });
|
||||
await userEvent.click(within(card).getByRole("button", { name: "Teil hinzufügen" }));
|
||||
expect(await screen.findByRole("heading", { name: "Teil hinzufügen" })).toBeInTheDocument();
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("heading", { name: "Teil hinzufügen" })).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
await userEvent.click(
|
||||
within(within(card).getByRole("row", { name: /Futtertrog XL/ })).getByRole("button", {
|
||||
name: /Bearbeiten/,
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByRole("heading", { name: "Teil bearbeiten" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the system dialog from the card", async () => {
|
||||
renderPage();
|
||||
const card = await screen.findByRole("region", { name: "Dadant" });
|
||||
await userEvent.click(within(card).getByRole("button", { name: "System bearbeiten" }));
|
||||
expect(await screen.findByRole("heading", { name: "System bearbeiten" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when there is no system yet", async () => {
|
||||
vi.mocked(fetchHiveSystems).mockResolvedValue([]);
|
||||
renderPage();
|
||||
expect(await screen.findByText(/Noch kein Beutensystem/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Boxes, Pencil, Plus, ShieldOff } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchHiveSystems, hiveKeys } from "@/lib/hive/api";
|
||||
import { updateHivePartType } from "@/lib/hive/catalog-api";
|
||||
import { serverMessage, templatePartCount } from "@/lib/hive/catalog-form";
|
||||
import type { HivePartTypeDto, HiveSystemDto } from "@/lib/hive/types";
|
||||
import { PartTypeDialog } from "./part-type-dialog";
|
||||
import { ActiveChip, PartTypeTable } from "./part-type-table";
|
||||
import { SystemDialog } from "./system-dialog";
|
||||
|
||||
const MANAGE_PERMISSION = "hive-systems:manage";
|
||||
|
||||
interface SystemCardProps {
|
||||
system: HiveSystemDto;
|
||||
pendingId: string | null;
|
||||
onEditSystem: () => void;
|
||||
onAddPart: () => void;
|
||||
onEditPart: (partType: HivePartTypeDto) => void;
|
||||
onToggleActive: (partType: HivePartTypeDto) => void;
|
||||
}
|
||||
|
||||
function SystemCard({
|
||||
system,
|
||||
pendingId,
|
||||
onEditSystem,
|
||||
onAddPart,
|
||||
onEditPart,
|
||||
onToggleActive,
|
||||
}: SystemCardProps) {
|
||||
const headingId = `hive-system-${system.id}`;
|
||||
const templateParts = templatePartCount(system.template);
|
||||
return (
|
||||
<section aria-labelledby={headingId}>
|
||||
<Card>
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle>
|
||||
<h2 id={headingId} className="font-heading text-lg font-semibold">
|
||||
{system.name}
|
||||
</h2>
|
||||
</CardTitle>
|
||||
<ActiveChip active={system.active} data-system-chip="" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="touch" onClick={onEditSystem}>
|
||||
<Pencil aria-hidden />
|
||||
System bearbeiten
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Rähmchen {system.frameWidthMm} × {system.frameHeightMm} mm · Grundfläche{" "}
|
||||
{system.footprintLMm} × {system.footprintWMm} mm
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{templateParts === null ? "Keine Vorlage" : `Vorlage: ${templateParts} Teile`}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<PartTypeTable
|
||||
partTypes={system.partTypes}
|
||||
pendingId={pendingId}
|
||||
onEdit={onEditPart}
|
||||
onToggleActive={onToggleActive}
|
||||
/>
|
||||
<Button type="button" size="touch" className="self-start" onClick={onAddPart}>
|
||||
<Plus aria-hidden />
|
||||
Teil hinzufügen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** The part dialog is open for a new part (no `partType`) or for an existing one. */
|
||||
interface PartDialogTarget {
|
||||
systemId: string;
|
||||
partType?: HivePartTypeDto;
|
||||
}
|
||||
|
||||
function HiveSystemsContent() {
|
||||
const queryClient = useQueryClient();
|
||||
const systemsQuery = useQuery({ queryKey: hiveKeys.systems, queryFn: fetchHiveSystems });
|
||||
const [partTarget, setPartTarget] = useState<PartDialogTarget | null>(null);
|
||||
const [systemTarget, setSystemTarget] = useState<{ system?: HiveSystemDto } | null>(null);
|
||||
const [toggleError, setToggleError] = useState<string | null>(null);
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (partType: HivePartTypeDto) =>
|
||||
updateHivePartType(partType.id, { active: !partType.active }),
|
||||
onMutate: () => setToggleError(null),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: hiveKeys.systems }),
|
||||
onError: (error: unknown) =>
|
||||
setToggleError(serverMessage(error, "Die Sichtbarkeit konnte nicht geändert werden.")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
icon={Boxes}
|
||||
title="Beutensysteme"
|
||||
description="Welche Teile es pro System gibt, wie groß sie sind und was ihr Zustand zeigt."
|
||||
actions={
|
||||
<Button type="button" size="touch" onClick={() => setSystemTarget({})}>
|
||||
<Plus aria-hidden />
|
||||
System hinzufügen
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{toggleError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{toggleError}
|
||||
</p>
|
||||
) : null}
|
||||
<QueryState
|
||||
query={systemsQuery}
|
||||
what="Beutensysteme"
|
||||
empty={
|
||||
<EmptyState
|
||||
icon={Boxes}
|
||||
title="Noch kein Beutensystem"
|
||||
description="Lege ein System an, damit Völker einen Beutenaufbau bekommen können."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(systems) =>
|
||||
systems.map((system) => (
|
||||
<SystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
pendingId={toggle.isPending ? toggle.variables.id : null}
|
||||
onEditSystem={() => setSystemTarget({ system })}
|
||||
onAddPart={() => setPartTarget({ systemId: system.id })}
|
||||
onEditPart={(partType) => setPartTarget({ systemId: system.id, partType })}
|
||||
onToggleActive={(partType) => toggle.mutate(partType)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</QueryState>
|
||||
<PartTypeDialog
|
||||
systemId={partTarget?.systemId ?? ""}
|
||||
partType={partTarget?.partType}
|
||||
open={partTarget !== null}
|
||||
onOpenChange={(open) => (open ? undefined : setPartTarget(null))}
|
||||
/>
|
||||
<SystemDialog
|
||||
system={systemTarget?.system}
|
||||
open={systemTarget !== null}
|
||||
onOpenChange={(open) => (open ? undefined : setSystemTarget(null))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HiveSystemsPage() {
|
||||
const { user } = useAuth();
|
||||
if (!user?.permissions.includes(MANAGE_PERMISSION)) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={ShieldOff}
|
||||
title="Du hast keine Berechtigung für die Beutensysteme."
|
||||
description="Frag eine Administratorin oder einen Administrator."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <HiveSystemsContent />;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { PartTypeDialog } from "./part-type-dialog";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { createHivePartType, updateHivePartType } from "@/lib/hive/catalog-api";
|
||||
import type { HivePartTypeDto } from "@/lib/hive/types";
|
||||
|
||||
vi.mock("@/lib/hive/catalog-api", () => ({
|
||||
createHivePartType: vi.fn(),
|
||||
updateHivePartType: vi.fn(),
|
||||
}));
|
||||
|
||||
const feeder: HivePartTypeDto = {
|
||||
id: "pt1",
|
||||
systemId: "s1",
|
||||
kind: "FEEDER",
|
||||
name: "Futterzarge",
|
||||
articleRef: "FZ-1",
|
||||
lengthMm: 500,
|
||||
widthMm: 480,
|
||||
heightMm: 200,
|
||||
frameSlots: null,
|
||||
boxDepth: null,
|
||||
stateSchema: [
|
||||
{
|
||||
key: "syrup",
|
||||
label: "Flüssigfutter",
|
||||
kind: "level",
|
||||
unit: "l",
|
||||
max: 3,
|
||||
step: 0.5,
|
||||
feedType: "FEED_SYRUP",
|
||||
},
|
||||
],
|
||||
active: true,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
updatedAt: "2026-10-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function renderDialog(partType?: HivePartTypeDto) {
|
||||
const onOpenChange = vi.fn();
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PartTypeDialog systemId="s1" partType={partType} open onOpenChange={onOpenChange} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onOpenChange, invalidate };
|
||||
}
|
||||
|
||||
const typeInto = async (label: RegExp | string, text: string) => {
|
||||
const input = screen.getByLabelText(label);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, text);
|
||||
};
|
||||
|
||||
async function pickBox() {
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Art des Teils/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Zarge" }));
|
||||
}
|
||||
|
||||
describe("PartTypeDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(createHivePartType).mockReset();
|
||||
vi.mocked(updateHivePartType).mockReset();
|
||||
vi.mocked(createHivePartType).mockResolvedValue({} as never);
|
||||
vi.mocked(updateHivePartType).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
it("creates a box with frame slots and refreshes the catalog", async () => {
|
||||
const { invalidate, onOpenChange } = renderDialog();
|
||||
expect(screen.getByRole("heading", { name: "Teil hinzufügen" })).toBeInTheDocument();
|
||||
|
||||
await typeInto(/^Name/, "Zarge DNM");
|
||||
await pickBox();
|
||||
await typeInto(/^Länge/, "500");
|
||||
await typeInto(/^Breite/, "480");
|
||||
await typeInto(/^Höhe/, "240");
|
||||
await typeInto(/^Rähmchenplätze/, "10");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createHivePartType).toHaveBeenCalledWith("s1", {
|
||||
kind: "BOX",
|
||||
name: "Zarge DNM",
|
||||
lengthMm: 500,
|
||||
widthMm: 480,
|
||||
heightMm: 240,
|
||||
boxDepth: "FULL",
|
||||
frameSlots: 10,
|
||||
}),
|
||||
);
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["hive-systems"] });
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("blocks saving without a name and sends nothing", async () => {
|
||||
renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Name/);
|
||||
expect(createHivePartType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps frame slots at the server limit", async () => {
|
||||
renderDialog();
|
||||
await typeInto(/^Name/, "Zarge");
|
||||
await pickBox();
|
||||
await typeInto(/^Länge/, "500");
|
||||
await typeInto(/^Breite/, "480");
|
||||
await typeInto(/^Höhe/, "240");
|
||||
await typeInto(/^Rähmchenplätze/, "25");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() =>
|
||||
expect(createHivePartType).toHaveBeenCalledWith("s1", expect.objectContaining({ frameSlots: 20 })),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the frame slots of a box", async () => {
|
||||
renderDialog();
|
||||
await typeInto(/^Name/, "Zarge");
|
||||
await pickBox();
|
||||
await typeInto(/^Länge/, "500");
|
||||
await typeInto(/^Breite/, "480");
|
||||
await typeInto(/^Höhe/, "240");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Rähmchenplätze/);
|
||||
expect(createHivePartType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edit: sends only the changed dimension as PATCH", async () => {
|
||||
const { onOpenChange } = renderDialog(feeder);
|
||||
expect(screen.getByRole("heading", { name: "Teil bearbeiten" })).toBeInTheDocument();
|
||||
await typeInto(/^Höhe/, "250");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() => expect(updateHivePartType).toHaveBeenCalledWith("pt1", { heightMm: 250 }));
|
||||
expect(createHivePartType).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("edit: the kind is shown but cannot be changed", () => {
|
||||
renderDialog(feeder);
|
||||
expect(screen.queryByRole("combobox", { name: /Art des Teils/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Futterzarge", { selector: "dd" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("existing state fields are read-only and cannot be removed", () => {
|
||||
renderDialog(feeder);
|
||||
const list = screen.getByRole("list", { name: "Bestehende Zustandsfelder" });
|
||||
expect(within(list).getByText(/Flüssigfutter/)).toBeInTheDocument();
|
||||
expect(within(list).queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(within(list).queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /entfernen|löschen/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("appends a level field after the existing ones", async () => {
|
||||
renderDialog(feeder);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
|
||||
await typeInto(/^Schlüssel/, "paste");
|
||||
await typeInto(/^Feldname/, "Futterteig");
|
||||
await typeInto(/^Einheit/, "kg");
|
||||
await typeInto(/^Höchstwert/, "2,5");
|
||||
await typeInto(/^Schrittweite/, "0,5");
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Futtertyp/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Futterteig" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateHivePartType).toHaveBeenCalledWith("pt1", {
|
||||
stateSchema: [
|
||||
feeder.stateSchema[0],
|
||||
{
|
||||
key: "paste",
|
||||
label: "Futterteig",
|
||||
kind: "level",
|
||||
unit: "kg",
|
||||
max: 2.5,
|
||||
step: 0.5,
|
||||
feedType: "FEED_PASTE",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("appends a choice field with option labels", async () => {
|
||||
renderDialog(feeder);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
|
||||
await userEvent.click(screen.getByRole("combobox", { name: /Art des Feldes/ }));
|
||||
await userEvent.click(await screen.findByRole("option", { name: "Auswahl" }));
|
||||
await typeInto(/^Schlüssel/, "lid");
|
||||
await typeInto(/^Feldname/, "Deckel");
|
||||
await typeInto("Option 1 Wert", "open");
|
||||
await typeInto("Option 1 Bezeichnung", "offen");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Option hinzufügen" }));
|
||||
await typeInto("Option 2 Wert", "closed");
|
||||
await typeInto("Option 2 Bezeichnung", "zu");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateHivePartType).toHaveBeenCalledWith("pt1", {
|
||||
stateSchema: [
|
||||
feeder.stateSchema[0],
|
||||
{
|
||||
key: "lid",
|
||||
label: "Deckel",
|
||||
kind: "choice",
|
||||
options: ["open", "closed"],
|
||||
optionLabels: ["offen", "zu"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a new field whose key already exists", async () => {
|
||||
renderDialog(feeder);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
|
||||
await typeInto(/^Schlüssel/, "syrup");
|
||||
await typeInto(/^Feldname/, "Nochmal");
|
||||
await typeInto(/^Höchstwert/, "1");
|
||||
await typeInto(/^Schrittweite/, "1");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Schlüssel/);
|
||||
expect(updateHivePartType).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a new field can be dropped again before saving", async () => {
|
||||
renderDialog(feeder);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Feld hinzufügen" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Neues Feld verwerfen" }));
|
||||
expect(screen.queryByLabelText(/^Schlüssel/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the server 400 message inline", async () => {
|
||||
vi.mocked(updateHivePartType).mockRejectedValue(
|
||||
new ApiError(400, "x", {
|
||||
code: "SCHEMA_INVALID",
|
||||
messages: ["stateSchema[1].max must be positive"],
|
||||
}),
|
||||
);
|
||||
const { onOpenChange } = renderDialog(feeder);
|
||||
await typeInto(/^Höhe/, "250");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("stateSchema[1].max must be positive");
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { hiveKeys } from "@/lib/hive/api";
|
||||
import {
|
||||
createHivePartType,
|
||||
updateHivePartType,
|
||||
type HivePartTypeInput,
|
||||
type HivePartTypePatch,
|
||||
} from "@/lib/hive/catalog-api";
|
||||
import {
|
||||
MAX_DIMENSION_MM,
|
||||
MAX_FRAME_SLOTS,
|
||||
draftProblem,
|
||||
emptyDraft,
|
||||
fieldFromDraft,
|
||||
serverMessage,
|
||||
type FieldDraft,
|
||||
} from "@/lib/hive/catalog-form";
|
||||
import { PART_KIND_LABELS } from "@/lib/hive/labels";
|
||||
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto } from "@/lib/hive/types";
|
||||
import { StateFieldEditor } from "./state-field-editor";
|
||||
import { StateFieldList } from "./state-field-list";
|
||||
|
||||
const BOX_DEPTH_LABELS: Record<HiveBoxDepth, string> = {
|
||||
FULL: "Volle Zarge",
|
||||
SHALLOW: "Flachzarge",
|
||||
};
|
||||
const MAX_NAME_LENGTH = 120;
|
||||
const MAX_ARTICLE_REF_LENGTH = 80;
|
||||
const DIMENSION_STEP_MM = 10;
|
||||
|
||||
interface PartTypeFormProps {
|
||||
systemId: string;
|
||||
partType: HivePartTypeDto | undefined;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
articleRef: string;
|
||||
kind: HivePartKind | "";
|
||||
lengthMm: string;
|
||||
widthMm: string;
|
||||
heightMm: string;
|
||||
boxDepth: HiveBoxDepth;
|
||||
frameSlots: string;
|
||||
}
|
||||
|
||||
function initialValues(partType: HivePartTypeDto | undefined): FormValues {
|
||||
return {
|
||||
name: partType?.name ?? "",
|
||||
articleRef: partType?.articleRef ?? "",
|
||||
kind: partType?.kind ?? "",
|
||||
lengthMm: partType ? String(partType.lengthMm) : "",
|
||||
widthMm: partType ? String(partType.widthMm) : "",
|
||||
heightMm: partType ? String(partType.heightMm) : "",
|
||||
boxDepth: partType?.boxDepth ?? "FULL",
|
||||
frameSlots: partType?.frameSlots == null ? "" : String(partType.frameSlots),
|
||||
};
|
||||
}
|
||||
|
||||
function isDimension(text: string): boolean {
|
||||
const value = Number(text);
|
||||
return text !== "" && Number.isInteger(value) && value >= 1 && value <= MAX_DIMENSION_MM;
|
||||
}
|
||||
|
||||
function valuesProblem(values: FormValues): string | null {
|
||||
if (values.name.trim() === "") return "Bitte einen Namen eingeben.";
|
||||
if (values.kind === "") return "Bitte die Art des Teils wählen.";
|
||||
if (![values.lengthMm, values.widthMm, values.heightMm].every(isDimension)) {
|
||||
return `Länge, Breite und Höhe müssen ganze Millimeter zwischen 1 und ${MAX_DIMENSION_MM} sein.`;
|
||||
}
|
||||
if (values.kind === "BOX" && values.frameSlots === "") {
|
||||
return "Eine Zarge braucht die Zahl der Rähmchenplätze.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Only what differs from the stored part type, so a PATCH never touches the rest. */
|
||||
function patchFrom(
|
||||
values: FormValues,
|
||||
partType: HivePartTypeDto,
|
||||
newField: FieldDraft | null,
|
||||
): HivePartTypePatch {
|
||||
const patch: HivePartTypePatch = {};
|
||||
const name = values.name.trim();
|
||||
if (name !== partType.name) patch.name = name;
|
||||
const articleRef = values.articleRef.trim();
|
||||
if (articleRef !== (partType.articleRef ?? "")) patch.articleRef = articleRef === "" ? null : articleRef;
|
||||
if (Number(values.lengthMm) !== partType.lengthMm) patch.lengthMm = Number(values.lengthMm);
|
||||
if (Number(values.widthMm) !== partType.widthMm) patch.widthMm = Number(values.widthMm);
|
||||
if (Number(values.heightMm) !== partType.heightMm) patch.heightMm = Number(values.heightMm);
|
||||
if (partType.kind === "BOX") {
|
||||
if (values.boxDepth !== partType.boxDepth) patch.boxDepth = values.boxDepth;
|
||||
if (Number(values.frameSlots) !== partType.frameSlots) patch.frameSlots = Number(values.frameSlots);
|
||||
}
|
||||
if (newField) patch.stateSchema = [...partType.stateSchema, fieldFromDraft(newField)];
|
||||
return patch;
|
||||
}
|
||||
|
||||
function inputFrom(values: FormValues, newField: FieldDraft | null): HivePartTypeInput {
|
||||
const kind = values.kind as HivePartKind;
|
||||
const articleRef = values.articleRef.trim();
|
||||
return {
|
||||
kind,
|
||||
name: values.name.trim(),
|
||||
...(articleRef === "" ? {} : { articleRef }),
|
||||
lengthMm: Number(values.lengthMm),
|
||||
widthMm: Number(values.widthMm),
|
||||
heightMm: Number(values.heightMm),
|
||||
...(kind === "BOX" ? { boxDepth: values.boxDepth, frameSlots: Number(values.frameSlots) } : {}),
|
||||
...(newField ? { stateSchema: [fieldFromDraft(newField)] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function DimensionField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label} required>
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={1}
|
||||
max={MAX_DIMENSION_MM}
|
||||
step={DIMENSION_STEP_MM}
|
||||
unitLabel="mm"
|
||||
decrementLabel={`${label} verringern`}
|
||||
incrementLabel={`${label} erhöhen`}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function PartTypeForm({ systemId, partType, onSaved }: PartTypeFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [values, setValues] = useState(() => initialValues(partType));
|
||||
const [newField, setNewField] = useState<FieldDraft | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const existingSchema = partType?.stateSchema ?? [];
|
||||
const setValue = <K extends keyof FormValues>(key: K, value: FormValues[K]) =>
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!partType) return createHivePartType(systemId, inputFrom(values, newField));
|
||||
const patch = patchFrom(values, partType, newField);
|
||||
// Nothing changed: no request needed.
|
||||
if (Object.keys(patch).length > 0) await updateHivePartType(partType.id, patch);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.systems });
|
||||
onSaved();
|
||||
},
|
||||
onError: (mutationError: unknown) =>
|
||||
setError(serverMessage(mutationError, "Das Teil konnte nicht gespeichert werden.")),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const problem =
|
||||
valuesProblem(values) ??
|
||||
(newField
|
||||
? draftProblem(newField, existingSchema.map((field) => field.key))
|
||||
: null);
|
||||
setError(problem);
|
||||
if (problem === null) mutation.mutate();
|
||||
}
|
||||
|
||||
const isBox = values.kind === "BOX";
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<Field label="Name" required>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
value={values.name}
|
||||
onChange={(event) => setValue("name", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Artikelnummer">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
maxLength={MAX_ARTICLE_REF_LENGTH}
|
||||
value={values.articleRef}
|
||||
onChange={(event) => setValue("articleRef", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{partType ? (
|
||||
<dl className="flex flex-col gap-1 text-sm">
|
||||
<dt className="font-medium">Art des Teils</dt>
|
||||
<dd>{PART_KIND_LABELS[partType.kind]}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<Field label="Art des Teils" required hint="Lässt sich später nicht mehr ändern.">
|
||||
{({ id, describedBy }) => (
|
||||
<EnumSelect
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
labels={PART_KIND_LABELS}
|
||||
noneLabel="Bitte wählen"
|
||||
noneIsPlaceholder
|
||||
value={values.kind}
|
||||
onChange={(kind) => setValue("kind", kind)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<DimensionField label="Länge" value={values.lengthMm} onChange={(v) => setValue("lengthMm", v)} />
|
||||
<DimensionField label="Breite" value={values.widthMm} onChange={(v) => setValue("widthMm", v)} />
|
||||
<DimensionField label="Höhe" value={values.heightMm} onChange={(v) => setValue("heightMm", v)} />
|
||||
{isBox ? (
|
||||
<>
|
||||
<Field label="Bauart der Zarge" required>
|
||||
{({ id, describedBy }) => (
|
||||
<EnumSelect
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
labels={BOX_DEPTH_LABELS}
|
||||
value={values.boxDepth}
|
||||
onChange={(boxDepth) => setValue("boxDepth", boxDepth)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Rähmchenplätze" required>
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
value={values.frameSlots}
|
||||
onChange={(frameSlots) => setValue("frameSlots", frameSlots)}
|
||||
max={MAX_FRAME_SLOTS}
|
||||
unitLabel="Plätze"
|
||||
decrementLabel="Rähmchenplätze verringern"
|
||||
incrementLabel="Rähmchenplätze erhöhen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-col gap-3" aria-labelledby="state-fields-heading">
|
||||
<h3 id="state-fields-heading" className="text-sm font-medium">
|
||||
Zustandsfelder
|
||||
</h3>
|
||||
<StateFieldList fields={existingSchema} />
|
||||
{newField ? (
|
||||
<StateFieldEditor
|
||||
draft={newField}
|
||||
onChange={setNewField}
|
||||
onDiscard={() => setNewField(null)}
|
||||
allowFeedType={values.kind === "FEEDER"}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="self-start"
|
||||
onClick={() => setNewField(emptyDraft())}
|
||||
>
|
||||
<Plus aria-hidden />
|
||||
Feld hinzufügen
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="submit" size="touch" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface PartTypeDialogProps {
|
||||
systemId: string;
|
||||
/** Without a part type the dialog adds a new one. */
|
||||
partType?: HivePartTypeDto;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function PartTypeDialog({ systemId, partType, open, onOpenChange }: PartTypeDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{partType ? "Teil bearbeiten" : "Teil hinzufügen"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* Mounted only while open, so every opening starts from the stored values. */}
|
||||
{open ? (
|
||||
<PartTypeForm
|
||||
key={partType?.id ?? "new"}
|
||||
systemId={systemId}
|
||||
partType={partType}
|
||||
onSaved={() => onOpenChange(false)}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, EyeOff, Pencil } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { describeStateFields } from "@/lib/hive/catalog-form";
|
||||
import { PART_KIND_LABELS } from "@/lib/hive/labels";
|
||||
import type { HivePartTypeDto } from "@/lib/hive/types";
|
||||
|
||||
/** Active or hidden in words plus an icon - never colour alone. */
|
||||
interface ActiveChipProps {
|
||||
active: boolean;
|
||||
/** Marker for the chip of a whole system (tests tell it from the part type chips). */
|
||||
"data-system-chip"?: string;
|
||||
}
|
||||
|
||||
export function ActiveChip({ active, ...rest }: ActiveChipProps) {
|
||||
const Icon = active ? Eye : EyeOff;
|
||||
return (
|
||||
<Badge variant={active ? "secondary" : "outline"} {...rest}>
|
||||
<Icon aria-hidden />
|
||||
{active ? "aktiv" : "ausgeblendet"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
interface PartTypeTableProps {
|
||||
partTypes: readonly HivePartTypeDto[];
|
||||
/** The part type whose visibility is being changed right now. */
|
||||
pendingId: string | null;
|
||||
onEdit: (partType: HivePartTypeDto) => void;
|
||||
onToggleActive: (partType: HivePartTypeDto) => void;
|
||||
}
|
||||
|
||||
export function PartTypeTable({ partTypes, pendingId, onEdit, onToggleActive }: PartTypeTableProps) {
|
||||
if (partTypes.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Noch keine Teile in diesem System.</p>;
|
||||
}
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Maße</TableHead>
|
||||
<TableHead>Plätze</TableHead>
|
||||
<TableHead>Zustandsfelder</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{partTypes.map((partType) => (
|
||||
<TableRow key={partType.id} className={partType.active ? undefined : "text-muted-foreground"}>
|
||||
<TableCell className="font-medium">{partType.name}</TableCell>
|
||||
<TableCell>{PART_KIND_LABELS[partType.kind]}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{partType.lengthMm} × {partType.widthMm} × {partType.heightMm} mm
|
||||
</TableCell>
|
||||
<TableCell>{partType.frameSlots ?? "–"}</TableCell>
|
||||
<TableCell className="min-w-48 whitespace-normal">
|
||||
{describeStateFields(partType.stateSchema)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ActiveChip active={partType.active} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="touch" onClick={() => onEdit(partType)}>
|
||||
<Pencil aria-hidden />
|
||||
Bearbeiten<span className="sr-only"> {partType.name}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={pendingId === partType.id}
|
||||
onClick={() => onToggleActive(partType)}
|
||||
>
|
||||
{partType.active ? <EyeOff aria-hidden /> : <Eye aria-hidden />}
|
||||
{partType.active ? "Ausblenden" : "Einblenden"}
|
||||
<span className="sr-only"> {partType.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumSelect } from "@/components/form/enum-select";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { FEED_TYPES, FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||
import {
|
||||
MAX_FIELD_LABEL_LENGTH,
|
||||
MAX_KEY_LENGTH,
|
||||
MAX_OPTION_LENGTH,
|
||||
MAX_UNIT_LENGTH,
|
||||
type FieldDraft,
|
||||
} from "@/lib/hive/catalog-form";
|
||||
|
||||
const FIELD_KIND_LABELS = { level: "Füllstand (Zahl)", choice: "Auswahl" } as const;
|
||||
|
||||
interface StateFieldEditorProps {
|
||||
draft: FieldDraft;
|
||||
onChange: (draft: FieldDraft) => void;
|
||||
onDiscard: () => void;
|
||||
/** Only feeders may tie a level to a feed type. */
|
||||
allowFeedType: boolean;
|
||||
}
|
||||
|
||||
function LevelInputs({ draft, onChange, allowFeedType }: Omit<StateFieldEditorProps, "onDiscard">) {
|
||||
return (
|
||||
<>
|
||||
<Field label="Einheit" hint="Zum Beispiel l oder kg.">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
maxLength={MAX_UNIT_LENGTH}
|
||||
value={draft.unit}
|
||||
onChange={(event) => onChange({ ...draft, unit: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Höchstwert" required>
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
value={draft.max}
|
||||
onChange={(max) => onChange({ ...draft, max })}
|
||||
decimals={2}
|
||||
step={0.5}
|
||||
unitLabel={draft.unit || "Einheiten"}
|
||||
decrementLabel="Höchstwert verringern"
|
||||
incrementLabel="Höchstwert erhöhen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Schrittweite" required>
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
value={draft.step}
|
||||
onChange={(step) => onChange({ ...draft, step })}
|
||||
decimals={2}
|
||||
step={0.25}
|
||||
unitLabel={draft.unit || "Einheiten"}
|
||||
decrementLabel="Schrittweite verringern"
|
||||
incrementLabel="Schrittweite erhöhen"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{allowFeedType ? (
|
||||
<Field label="Futtertyp" hint="Damit schlägt die App eine Fütterung vor.">
|
||||
{({ id, describedBy }) => (
|
||||
<EnumSelect
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
labels={FEED_TYPE_LABELS}
|
||||
values={FEED_TYPES}
|
||||
noneLabel="Kein Futtertyp"
|
||||
value={draft.feedType}
|
||||
onChange={(feedType) => onChange({ ...draft, feedType })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionRows({ draft, onChange }: Pick<StateFieldEditorProps, "draft" | "onChange">) {
|
||||
const setOption = (index: number, patch: Partial<FieldDraft["options"][number]>) =>
|
||||
onChange({
|
||||
...draft,
|
||||
options: draft.options.map((option, i) => (i === index ? { ...option, ...patch } : option)),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{draft.options.map((option, index) => (
|
||||
<div key={index} className="flex items-end gap-2">
|
||||
<Input
|
||||
aria-label={`Option ${index + 1} Wert`}
|
||||
placeholder="Wert"
|
||||
maxLength={MAX_OPTION_LENGTH}
|
||||
value={option.value}
|
||||
onChange={(event) => setOption(index, { value: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
aria-label={`Option ${index + 1} Bezeichnung`}
|
||||
placeholder="Bezeichnung"
|
||||
maxLength={MAX_OPTION_LENGTH}
|
||||
value={option.label}
|
||||
onChange={(event) => setOption(index, { label: event.target.value })}
|
||||
/>
|
||||
{draft.options.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="touch"
|
||||
aria-label={`Option ${index + 1} entfernen`}
|
||||
onClick={() =>
|
||||
onChange({ ...draft, options: draft.options.filter((_, i) => i !== index) })
|
||||
}
|
||||
>
|
||||
<X aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="self-start"
|
||||
onClick={() => onChange({ ...draft, options: [...draft.options, { value: "", label: "" }] })}
|
||||
>
|
||||
<Plus aria-hidden />
|
||||
Option hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inputs of the one new state field; it is appended to the schema on save. */
|
||||
export function StateFieldEditor({ draft, onChange, onDiscard, allowFeedType }: StateFieldEditorProps) {
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-3 rounded-xl border border-border p-3">
|
||||
<legend className="px-1 text-sm font-medium">Neues Zustandsfeld</legend>
|
||||
<Field label="Art des Feldes" required>
|
||||
{({ id }) => (
|
||||
<EnumSelect
|
||||
id={id}
|
||||
labels={FIELD_KIND_LABELS}
|
||||
value={draft.kind}
|
||||
onChange={(kind) => onChange({ ...draft, kind })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Schlüssel" required hint="Technischer Name, später nicht mehr änderbar.">
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
maxLength={MAX_KEY_LENGTH}
|
||||
value={draft.key}
|
||||
onChange={(event) => onChange({ ...draft, key: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Feldname" required>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
maxLength={MAX_FIELD_LABEL_LENGTH}
|
||||
value={draft.label}
|
||||
onChange={(event) => onChange({ ...draft, label: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{draft.kind === "level" ? (
|
||||
<LevelInputs draft={draft} onChange={onChange} allowFeedType={allowFeedType} />
|
||||
) : (
|
||||
<OptionRows draft={draft} onChange={onChange} />
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="touch" className="self-start" onClick={onDiscard}>
|
||||
<X aria-hidden />
|
||||
Neues Feld verwerfen
|
||||
</Button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { StateField } from "@/lib/hive/types";
|
||||
|
||||
function summary(field: StateField): string {
|
||||
if (field.kind === "choice") return `Auswahl: ${field.optionLabels.join(" / ")}`;
|
||||
const unit = field.unit === "" ? "" : ` ${field.unit}`;
|
||||
return `Füllstand 0–${String(field.max).replace(".", ",")}${unit}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing state fields, read only: the schema may only grow (the server
|
||||
* answers SCHEMA_NOT_ADDITIVE otherwise), so there is nothing to edit or remove.
|
||||
*/
|
||||
export function StateFieldList({ fields }: { fields: readonly StateField[] }) {
|
||||
if (fields.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Noch keine Zustandsfelder.</p>;
|
||||
}
|
||||
return (
|
||||
<ul aria-label="Bestehende Zustandsfelder" className="flex flex-col gap-1 text-sm">
|
||||
{fields.map((field) => (
|
||||
<li key={field.key} className="rounded-lg bg-muted/50 px-3 py-2">
|
||||
<span className="font-medium">{field.label}</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
({field.key}) · {summary(field)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { SystemDialog } from "./system-dialog";
|
||||
import { createHiveSystem, updateHiveSystem } from "@/lib/hive/catalog-api";
|
||||
import type { HiveSystemDto } from "@/lib/hive/types";
|
||||
|
||||
vi.mock("@/lib/hive/catalog-api", () => ({
|
||||
createHiveSystem: vi.fn(),
|
||||
updateHiveSystem: vi.fn(),
|
||||
}));
|
||||
|
||||
const system: HiveSystemDto = {
|
||||
id: "s1",
|
||||
name: "Dadant",
|
||||
frameWidthMm: 435,
|
||||
frameHeightMm: 300,
|
||||
footprintLMm: 500,
|
||||
footprintWMm: 480,
|
||||
template: null,
|
||||
active: true,
|
||||
partTypes: [],
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
updatedAt: "2026-10-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function renderDialog(value?: HiveSystemDto) {
|
||||
const onOpenChange = vi.fn();
|
||||
const client = new QueryClient();
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<SystemDialog system={value} open onOpenChange={onOpenChange} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onOpenChange, invalidate };
|
||||
}
|
||||
|
||||
const typeInto = async (label: RegExp, text: string) => {
|
||||
const input = screen.getByLabelText(label);
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, text);
|
||||
};
|
||||
|
||||
describe("SystemDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(createHiveSystem).mockReset().mockResolvedValue({} as never);
|
||||
vi.mocked(updateHiveSystem).mockReset().mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
it("edit: sends the changed footprint as PATCH and refreshes the catalog", async () => {
|
||||
const { invalidate, onOpenChange } = renderDialog(system);
|
||||
await typeInto(/^Grundfläche Länge/, "520");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() => expect(updateHiveSystem).toHaveBeenCalledWith("s1", { footprintLMm: 520 }));
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["hive-systems"] });
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("create: needs a name and sends all four dimensions", async () => {
|
||||
renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/Name/);
|
||||
|
||||
await typeInto(/^Name/, "Zander");
|
||||
await typeInto(/^Rähmchen Breite/, "420");
|
||||
await typeInto(/^Rähmchen Höhe/, "220");
|
||||
await typeInto(/^Grundfläche Länge/, "450");
|
||||
await typeInto(/^Grundfläche Breite/, "430");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Speichern" }));
|
||||
await waitFor(() =>
|
||||
expect(createHiveSystem).toHaveBeenCalledWith({
|
||||
name: "Zander",
|
||||
frameWidthMm: 420,
|
||||
frameHeightMm: 220,
|
||||
footprintLMm: 450,
|
||||
footprintWMm: 430,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { hiveKeys } from "@/lib/hive/api";
|
||||
import {
|
||||
createHiveSystem,
|
||||
updateHiveSystem,
|
||||
type HiveSystemInput,
|
||||
type HiveSystemPatch,
|
||||
} from "@/lib/hive/catalog-api";
|
||||
import { MAX_DIMENSION_MM, serverMessage } from "@/lib/hive/catalog-form";
|
||||
import type { HiveSystemDto } from "@/lib/hive/types";
|
||||
|
||||
const MAX_NAME_LENGTH = 120;
|
||||
const DIMENSION_STEP_MM = 5;
|
||||
|
||||
type DimensionKey = "frameWidthMm" | "frameHeightMm" | "footprintLMm" | "footprintWMm";
|
||||
|
||||
const DIMENSIONS: { key: DimensionKey; label: string }[] = [
|
||||
{ key: "frameWidthMm", label: "Rähmchen Breite" },
|
||||
{ key: "frameHeightMm", label: "Rähmchen Höhe" },
|
||||
{ key: "footprintLMm", label: "Grundfläche Länge" },
|
||||
{ key: "footprintWMm", label: "Grundfläche Breite" },
|
||||
];
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
active: boolean;
|
||||
dimensions: Record<DimensionKey, string>;
|
||||
}
|
||||
|
||||
function initialValues(system: HiveSystemDto | undefined): FormValues {
|
||||
const text = (key: DimensionKey) => (system ? String(system[key]) : "");
|
||||
return {
|
||||
name: system?.name ?? "",
|
||||
active: system?.active ?? true,
|
||||
dimensions: {
|
||||
frameWidthMm: text("frameWidthMm"),
|
||||
frameHeightMm: text("frameHeightMm"),
|
||||
footprintLMm: text("footprintLMm"),
|
||||
footprintWMm: text("footprintWMm"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function problemOf(values: FormValues): string | null {
|
||||
if (values.name.trim() === "") return "Bitte einen Namen eingeben.";
|
||||
const valid = DIMENSIONS.every(({ key }) => {
|
||||
const value = Number(values.dimensions[key]);
|
||||
return values.dimensions[key] !== "" && Number.isInteger(value) && value >= 1 && value <= MAX_DIMENSION_MM;
|
||||
});
|
||||
return valid ? null : `Alle Maße müssen ganze Millimeter zwischen 1 und ${MAX_DIMENSION_MM} sein.`;
|
||||
}
|
||||
|
||||
function inputFrom(values: FormValues): HiveSystemInput {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
frameWidthMm: Number(values.dimensions.frameWidthMm),
|
||||
frameHeightMm: Number(values.dimensions.frameHeightMm),
|
||||
footprintLMm: Number(values.dimensions.footprintLMm),
|
||||
footprintWMm: Number(values.dimensions.footprintWMm),
|
||||
};
|
||||
}
|
||||
|
||||
/** Only what differs from the stored system. */
|
||||
function patchFrom(values: FormValues, system: HiveSystemDto): HiveSystemPatch {
|
||||
const input = inputFrom(values);
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== system[key as keyof HiveSystemInput]) patch[key] = value;
|
||||
}
|
||||
if (values.active !== system.active) patch.active = values.active;
|
||||
return patch as HiveSystemPatch;
|
||||
}
|
||||
|
||||
function SystemForm({ system, onSaved }: { system: HiveSystemDto | undefined; onSaved: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [values, setValues] = useState(() => initialValues(system));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!system) return createHiveSystem(inputFrom(values));
|
||||
const patch = patchFrom(values, system);
|
||||
// Nothing changed: no request needed.
|
||||
if (Object.keys(patch).length > 0) await updateHiveSystem(system.id, patch);
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.systems });
|
||||
onSaved();
|
||||
},
|
||||
onError: (mutationError: unknown) =>
|
||||
setError(serverMessage(mutationError, "Das System konnte nicht gespeichert werden.")),
|
||||
});
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const problem = problemOf(values);
|
||||
setError(problem);
|
||||
if (problem === null) mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit} noValidate>
|
||||
<Field label="Name" required>
|
||||
{({ id, describedBy }) => (
|
||||
<Input
|
||||
id={id}
|
||||
aria-describedby={describedBy}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
value={values.name}
|
||||
onChange={(event) => setValues({ ...values, name: event.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
{DIMENSIONS.map(({ key, label }) => (
|
||||
<Field key={key} label={label} required>
|
||||
{({ id, describedBy }) => (
|
||||
<NumberStepper
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
value={values.dimensions[key]}
|
||||
onChange={(value) =>
|
||||
setValues({ ...values, dimensions: { ...values.dimensions, [key]: value } })
|
||||
}
|
||||
min={1}
|
||||
max={MAX_DIMENSION_MM}
|
||||
step={DIMENSION_STEP_MM}
|
||||
unitLabel="mm"
|
||||
decrementLabel={`${label} verringern`}
|
||||
incrementLabel={`${label} erhöhen`}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
{system ? (
|
||||
<label className="flex min-h-11 items-center gap-3 text-sm" htmlFor="hive-system-active">
|
||||
<input
|
||||
id="hive-system-active"
|
||||
type="checkbox"
|
||||
className="size-4.5"
|
||||
checked={values.active}
|
||||
onChange={(event) => setValues({ ...values, active: event.target.checked })}
|
||||
/>
|
||||
Aktiv – ausgeblendete Systeme stehen bei neuen Beuten nicht zur Wahl
|
||||
</label>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="submit" size="touch" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface SystemDialogProps {
|
||||
/** Without a system the dialog creates a new one. */
|
||||
system?: HiveSystemDto;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SystemDialog({ system, open, onOpenChange }: SystemDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{system ? "System bearbeiten" : "System hinzufügen"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* Mounted only while open, so every opening starts from the stored values. */}
|
||||
{open ? <SystemForm system={system} onSaved={() => onOpenChange(false)} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,18 @@ describe("SettingsLayout", () => {
|
||||
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets a user with only hive-systems:manage into the settings area", () => {
|
||||
mockUser = { permissions: ["hive-systems:manage"] };
|
||||
|
||||
render(
|
||||
<SettingsLayout>
|
||||
<p>Einstellungen-Inhalt</p>
|
||||
</SettingsLayout>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Einstellungen-Inhalt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects to the dashboard when the user lacks every settings-area permission", () => {
|
||||
mockUser = { permissions: ["colonies:read"] };
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const SETTINGS_AREA_PERMISSIONS = [
|
||||
"backup:configure",
|
||||
"calendar:configure",
|
||||
"api-keys:manage",
|
||||
"hive-systems:manage",
|
||||
"system:read",
|
||||
];
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function NewTreatmentPage() {
|
||||
|
||||
|
||||
const [values, setValues] = useState<TreatmentFormValues>(() =>
|
||||
initialTreatmentValues(searchParams.get("colonyId") ?? ""),
|
||||
initialTreatmentValues(searchParams.get("colonyId") ?? "", searchParams),
|
||||
);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [medicineNote, setMedicineNote] = useState<string | null>(null);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { initialTreatmentValues } from "./treatment-form-values";
|
||||
|
||||
const params = (query: string) => new URLSearchParams(query);
|
||||
|
||||
describe("initialTreatmentValues prefill", () => {
|
||||
it("keeps the defaults without search params", () => {
|
||||
expect(initialTreatmentValues("c1", params("colonyId=c1"))).toMatchObject({
|
||||
colonyId: "c1",
|
||||
amount: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads date and a millilitre amount", () => {
|
||||
const values = initialTreatmentValues("c1", params("date=2026-10-12&amount=250&unit=ml"));
|
||||
expect(values).toMatchObject({ date: "2026-10-12", amount: "250 ml" });
|
||||
});
|
||||
|
||||
it("needs the unit ml for the amount", () => {
|
||||
expect(initialTreatmentValues("c1", params("amount=250")).amount).toBe("");
|
||||
expect(initialTreatmentValues("c1", params("amount=250&unit=l")).amount).toBe("");
|
||||
});
|
||||
|
||||
it.each([["date=2026-13-01"], ["date=x"], ["amount=-5&unit=ml"], ["amount=abc&unit=ml"]])(
|
||||
"ignores the invalid value in %s",
|
||||
(query) => {
|
||||
const defaults = initialTreatmentValues("c1");
|
||||
const values = initialTreatmentValues("c1", params(query));
|
||||
expect(values.date).toBe(defaults.date);
|
||||
expect(values.amount).toBe("");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { BulkCreateTreatmentInput } from "@/lib/bulk/api";
|
||||
import type { CaptureTarget } from "@/components/form/capture-target-toggle";
|
||||
import { toApiDate, todayLocalIsoDate } from "@/lib/dates";
|
||||
import { emptyToNull } from "@/lib/forms/empty-to-null";
|
||||
import { prefillAmount, prefillDay, type ParamSource } from "@/lib/forms/prefill-params";
|
||||
import { parsedDose } from "@/lib/treatments/dose";
|
||||
|
||||
/** Everything the form holds, as plain values that survive a draft save. */
|
||||
@@ -34,13 +35,18 @@ export interface TreatmentFormValues {
|
||||
prescribingVet: string;
|
||||
}
|
||||
|
||||
export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
|
||||
/**
|
||||
* Start values. Search params `date`, `amount` and `unit=ml` (the hive card's
|
||||
* „Als Behandlung eintragen“ link) prefill the fields; invalid ones are ignored.
|
||||
*/
|
||||
export function initialTreatmentValues(colonyId: string, params?: ParamSource): TreatmentFormValues {
|
||||
const amount = params?.get("unit") === "ml" ? prefillAmount(params.get("amount")) : null;
|
||||
return {
|
||||
colonyId,
|
||||
target: "single",
|
||||
bulkApiaryId: null,
|
||||
bulkColonyIds: [],
|
||||
date: todayLocalIsoDate(),
|
||||
date: (params && prefillDay(params.get("date"))) ?? todayLocalIsoDate(),
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
product: "",
|
||||
@@ -48,7 +54,7 @@ export function initialTreatmentValues(colonyId: string): TreatmentFormValues {
|
||||
medicineLotId: "",
|
||||
doseQuantity: "",
|
||||
activeIngredient: "",
|
||||
amount: "",
|
||||
amount: amount ? `${amount} ml` : "",
|
||||
method: "",
|
||||
reason: "",
|
||||
batchNumber: "",
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
--color-condition-weak-foreground: var(--condition-weak-foreground);
|
||||
--color-condition-critical: var(--condition-critical);
|
||||
--color-condition-critical-foreground: var(--condition-critical-foreground);
|
||||
--color-holo-bg: var(--holo-bg);
|
||||
--color-holo-edge: var(--holo-edge);
|
||||
--color-holo-edge-strong: var(--holo-edge-strong);
|
||||
--color-holo-fill: var(--holo-fill);
|
||||
--color-holo-label-bg: var(--holo-label-bg);
|
||||
--color-holo-label-fg: var(--holo-label-fg);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
@@ -126,6 +132,13 @@
|
||||
--gradient-sky: linear-gradient(135deg, oklch(0.68 0.1 235) 0%, oklch(0.5 0.1 255) 100%);
|
||||
--shadow-elevated: 0 1px 2px -1px oklch(0.3 0.05 50 / 8%), 0 8px 24px -8px oklch(0.3 0.05 50 / 16%);
|
||||
--shadow-soft: 0 1px 2px -1px oklch(0.3 0.05 50 / 6%), 0 4px 12px -4px oklch(0.3 0.05 50 / 10%);
|
||||
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
|
||||
--holo-bg: oklch(0.18 0.03 250);
|
||||
--holo-edge: oklch(0.78 0.14 220);
|
||||
--holo-edge-strong: oklch(0.88 0.12 215);
|
||||
--holo-fill: oklch(0.78 0.14 220 / 10%);
|
||||
--holo-label-bg: oklch(0.26 0.04 250);
|
||||
--holo-label-fg: oklch(0.95 0.02 230);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -179,6 +192,13 @@
|
||||
--gradient-sky: linear-gradient(135deg, oklch(0.55 0.09 235) 0%, oklch(0.34 0.09 255) 100%);
|
||||
--shadow-elevated: 0 1px 2px -1px oklch(0 0 0 / 30%), 0 8px 24px -8px oklch(0 0 0 / 45%);
|
||||
--shadow-soft: 0 1px 2px -1px oklch(0 0 0 / 25%), 0 4px 12px -4px oklch(0 0 0 / 30%);
|
||||
/* Hive "screen": always dark, in light and dark mode (spec 9.3). */
|
||||
--holo-bg: oklch(0.18 0.03 250);
|
||||
--holo-edge: oklch(0.78 0.14 220);
|
||||
--holo-edge-strong: oklch(0.88 0.12 215);
|
||||
--holo-fill: oklch(0.78 0.14 220 / 10%);
|
||||
--holo-label-bg: oklch(0.26 0.04 250);
|
||||
--holo-label-fg: oklch(0.95 0.02 230);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NumberStepper } from "./number-stepper";
|
||||
|
||||
function Harness({ initial = "", max }: { initial?: string; max?: number }) {
|
||||
function Harness({
|
||||
initial = "",
|
||||
max,
|
||||
min,
|
||||
decimals,
|
||||
step,
|
||||
onEmit,
|
||||
}: {
|
||||
initial?: string;
|
||||
max?: number;
|
||||
min?: number;
|
||||
decimals?: number;
|
||||
step?: number;
|
||||
onEmit?: (value: string) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(initial);
|
||||
return (
|
||||
<NumberStepper
|
||||
id="mites"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onEmit?.(next);
|
||||
}}
|
||||
max={max}
|
||||
min={min}
|
||||
decimals={decimals}
|
||||
step={step}
|
||||
unitLabel="Milben"
|
||||
decrementLabel="Eine Milbe weniger"
|
||||
incrementLabel="Eine Milbe mehr"
|
||||
@@ -152,4 +172,148 @@ describe("NumberStepper", () => {
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("45");
|
||||
});
|
||||
|
||||
describe("with decimals", () => {
|
||||
const more = () => screen.getByRole("button", { name: /Eine Milbe mehr/ });
|
||||
const less = () => screen.getByRole("button", { name: /Eine Milbe weniger/ });
|
||||
|
||||
it("typing „2,5“ emits the dot-decimal 2.5 and shows a comma", async () => {
|
||||
const onEmit = vi.fn();
|
||||
render(<Harness decimals={1} step={0.5} onEmit={onEmit} />);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "2,5");
|
||||
|
||||
expect(onEmit).toHaveBeenLastCalledWith("2.5");
|
||||
expect(screen.getByRole("textbox")).toHaveValue("2,5");
|
||||
});
|
||||
|
||||
it("accepts a dot as separator, but only one", async () => {
|
||||
render(<Harness decimals={1} step={0.5} />);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "2.,5");
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("2,5");
|
||||
});
|
||||
|
||||
it("limits the digits after the separator to decimals", async () => {
|
||||
render(<Harness decimals={1} step={0.5} />);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "2,55");
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("2,5");
|
||||
});
|
||||
|
||||
it("„+“ from „2,5“ shows „3“ (no trailing zero)", async () => {
|
||||
render(<Harness decimals={1} step={0.5} initial="2.5" />);
|
||||
|
||||
await userEvent.click(more());
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("3");
|
||||
});
|
||||
|
||||
it("„−“ steps down on the grid", async () => {
|
||||
render(<Harness decimals={1} step={0.5} initial="3" />);
|
||||
|
||||
await userEvent.click(less());
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("2,5");
|
||||
});
|
||||
|
||||
it("„+“ from empty starts at min + step and clamps at max", async () => {
|
||||
render(<Harness decimals={1} step={0.5} min={1} max={2} />);
|
||||
|
||||
await userEvent.click(more());
|
||||
expect(screen.getByRole("textbox")).toHaveValue("1,5");
|
||||
await userEvent.click(more());
|
||||
await userEvent.click(more());
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("2");
|
||||
});
|
||||
|
||||
it("has no float drift over repeated 0.1 steps", async () => {
|
||||
render(<Harness decimals={1} step={0.1} initial="0" />);
|
||||
|
||||
for (let i = 0; i < 3; i += 1) await userEvent.click(more());
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("0,3");
|
||||
});
|
||||
|
||||
it("shows „Nur Schritte von 0,5.“ for an off-grid value, wired to the field", () => {
|
||||
render(<Harness decimals={1} step={0.5} initial="2.25" />);
|
||||
const input = screen.getByRole("textbox");
|
||||
|
||||
expect(screen.getByText("Nur Schritte von 0,5.")).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input).toHaveAccessibleDescription(/Nur Schritte von 0,5\./);
|
||||
});
|
||||
|
||||
it("shows the error while typing off the grid and removes it on a grid value", async () => {
|
||||
render(<Harness decimals={2} step={0.5} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
|
||||
await userEvent.type(input, "2,25");
|
||||
expect(screen.getByText("Nur Schritte von 0,5.")).toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, "2,5");
|
||||
expect(screen.queryByText(/Nur Schritte/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tolerates float noise when checking the grid", () => {
|
||||
render(<Harness decimals={2} step={0.1} initial="0.3" />);
|
||||
|
||||
expect(screen.queryByText(/Nur Schritte/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces the grid error in a polite live region that is always present", () => {
|
||||
render(<Harness decimals={1} step={0.5} initial="2.5" />);
|
||||
|
||||
const live = screen.getByRole("status");
|
||||
expect(live).toBeEmptyDOMElement();
|
||||
expect(screen.getByRole("textbox")).toHaveAccessibleDescription(/Milben/);
|
||||
});
|
||||
|
||||
it("fills the same status element when an off-grid value is typed", async () => {
|
||||
render(<Harness decimals={2} step={0.5} />);
|
||||
const before = screen.getByRole("status");
|
||||
expect(before).toBeEmptyDOMElement();
|
||||
expect(before).not.toHaveClass("empty:hidden");
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "2,25");
|
||||
|
||||
const after = screen.getByRole("status");
|
||||
expect(after).toBe(before);
|
||||
expect(after).toHaveTextContent("Nur Schritte von 0,5.");
|
||||
});
|
||||
|
||||
it("writes the step with its own precision in the error", () => {
|
||||
render(<Harness decimals={1} step={0.25} initial="2.1" />);
|
||||
|
||||
expect(screen.getByText("Nur Schritte von 0,25.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("emits a leading zero for „,5“", async () => {
|
||||
const onEmit = vi.fn();
|
||||
render(<Harness decimals={1} step={0.5} onEmit={onEmit} />);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), ",5");
|
||||
|
||||
expect(onEmit).toHaveBeenLastCalledWith("0.5");
|
||||
});
|
||||
|
||||
it("displays a caller value with surplus zeros normalised", () => {
|
||||
render(<Harness decimals={1} step={0.5} initial="2.5000" />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("2,5");
|
||||
});
|
||||
|
||||
it("emits an empty string for empty input", async () => {
|
||||
const onEmit = vi.fn();
|
||||
render(<Harness decimals={1} step={0.5} initial="2.5" onEmit={onEmit} />);
|
||||
|
||||
await userEvent.clear(screen.getByRole("textbox"));
|
||||
|
||||
expect(onEmit).toHaveBeenLastCalledWith("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,12 @@ export interface NumberStepperProps {
|
||||
/** Highest count; typing more or tapping „+“ stops here. Default: 4 digits. */
|
||||
max?: number;
|
||||
step?: number;
|
||||
/**
|
||||
* Digits after the separator (default 0 = whole counts). With more, the field
|
||||
* takes "," or ".", shows a comma, emits a dot-decimal string ("2.5") and
|
||||
* keeps values on the `step` grid starting at `min`.
|
||||
*/
|
||||
decimals?: number;
|
||||
/** Visible unit next to the field, e.g. "Milben"; the name comes from the Field label. */
|
||||
unitLabel: string;
|
||||
decrementLabel: string;
|
||||
@@ -26,14 +32,59 @@ const STEP_BUTTON_CLASS =
|
||||
const MAX_DIGITS = 4;
|
||||
const MAX_VALUE = 10 ** MAX_DIGITS - 1;
|
||||
|
||||
const GRID_TOLERANCE = 1e-9;
|
||||
|
||||
function digitsOnly(text: string): string {
|
||||
return text.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
/** Digits plus at most one separator, at most `decimals` digits after it; dot-decimal out. */
|
||||
function sanitizeDecimal(text: string, decimals: number): string {
|
||||
const [whole = "", ...rest] = text.replace(/[^\d.,]/g, "").split(/[.,]/);
|
||||
const fraction = rest.join("").slice(0, decimals);
|
||||
return rest.length > 0 ? `${whole === "" ? "0" : whole}.${fraction}` : whole;
|
||||
}
|
||||
|
||||
/** At most `decimals` digits, no trailing zeros ("3", not "3.0"). */
|
||||
function formatNumber(value: number, decimals: number): string {
|
||||
return String(Number(value.toFixed(decimals)));
|
||||
}
|
||||
|
||||
/** Caller value for display: surplus fraction digits ("2.5000") are cut, typing in progress ("2.0") is kept. */
|
||||
function displayValue(value: string, decimals: number): string {
|
||||
const fractionLength = value.split(".")[1]?.length ?? 0;
|
||||
const number = Number(value);
|
||||
const shown =
|
||||
fractionLength > decimals && !Number.isNaN(number) ? formatNumber(number, decimals) : value;
|
||||
return toComma(shown);
|
||||
}
|
||||
|
||||
/** The step with its own precision, comma-separated (0.25 -> "0,25"). */
|
||||
function stepLabel(step: number): string {
|
||||
return toComma(String(step));
|
||||
}
|
||||
|
||||
function toComma(text: string): string {
|
||||
return text.replace(".", ",");
|
||||
}
|
||||
|
||||
/** Position on the step grid (min + n * step). */
|
||||
function gridPosition(value: number, min: number, step: number): number {
|
||||
return (value - min) / step;
|
||||
}
|
||||
|
||||
function isOnGrid(value: number, min: number, step: number): boolean {
|
||||
const position = gridPosition(value, min, step);
|
||||
return Math.abs(position - Math.round(position)) < GRID_TOLERANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* A count with big plus and minus buttons for gloved fingers; the field stays
|
||||
* typable. Whole counts only: digits only (no negatives or decimals), at most
|
||||
* 4 digits. Empty means "not counted": "+" then starts at min + step, "−" is
|
||||
* typable. By default whole counts only: digits only, at most 4 digits. With
|
||||
* `decimals` > 0 the field also takes one "," or "." (shown as a comma, emitted
|
||||
* as a dot-decimal string) and a value off the `step` grid shows an inline
|
||||
* error (the value is still emitted). Negatives cannot be typed in either mode
|
||||
* (the "-" is dropped), so `min` below 0 is only reachable through the buttons. Empty means "not counted": "+" then starts at min + step, "−" is
|
||||
* off, and "leeren" goes back to empty (a 0 would read as "counted: none").
|
||||
*/
|
||||
export function NumberStepper({
|
||||
@@ -43,6 +94,7 @@ export function NumberStepper({
|
||||
min = 0,
|
||||
max: maxProp,
|
||||
step = 1,
|
||||
decimals = 0,
|
||||
unitLabel,
|
||||
decrementLabel,
|
||||
incrementLabel,
|
||||
@@ -52,8 +104,29 @@ export function NumberStepper({
|
||||
const max = maxProp ?? MAX_VALUE;
|
||||
const [limitHit, setLimitHit] = useState(false);
|
||||
const unitId = `${id}-unit`;
|
||||
const current = value === "" ? null : Number(value);
|
||||
const canDecrement = current !== null && current - step >= min;
|
||||
const errorId = `${id}-grid-error`;
|
||||
const isDecimal = decimals > 0;
|
||||
const parsed = value === "" ? Number.NaN : Number(value);
|
||||
const current = Number.isNaN(parsed) ? null : parsed;
|
||||
// Derived from the value itself, so it always matches what the caller holds.
|
||||
const offGrid = isDecimal && current !== null && !isOnGrid(current, min, step);
|
||||
const gridError = offGrid ? `Nur Schritte von ${stepLabel(step)}.` : null;
|
||||
const canDecrement = current !== null && current - step >= min - GRID_TOLERANCE;
|
||||
|
||||
function format(next: number): string {
|
||||
return isDecimal ? formatNumber(next, decimals) : String(next);
|
||||
}
|
||||
|
||||
// Next grid point above / below; from an off-grid value it snaps to the grid.
|
||||
function stepUp(from: number): number {
|
||||
if (!isDecimal) return from + step;
|
||||
return min + (Math.floor(gridPosition(from, min, step) + GRID_TOLERANCE) + 1) * step;
|
||||
}
|
||||
|
||||
function stepDown(from: number): number {
|
||||
if (!isDecimal) return from - step;
|
||||
return min + (Math.ceil(gridPosition(from, min, step) - GRID_TOLERANCE) - 1) * step;
|
||||
}
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -64,15 +137,15 @@ export function NumberStepper({
|
||||
return;
|
||||
}
|
||||
setLimitHit(false);
|
||||
onChange(String(Math.min(max, current === null ? min + step : current + step)));
|
||||
onChange(format(Math.min(max, current === null ? min + step : stepUp(current))));
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
if (!canDecrement) return;
|
||||
const next = current - step;
|
||||
onChange(String(next));
|
||||
const next = Math.max(min, stepDown(current));
|
||||
onChange(format(next));
|
||||
// A button that turns disabled would drop keyboard focus to the page.
|
||||
if (next - step < min) inputRef.current?.focus();
|
||||
if (next - step < min - GRID_TOLERANCE) inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
@@ -80,7 +153,19 @@ export function NumberStepper({
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function handleDecimalInput(text: string) {
|
||||
const clean = sanitizeDecimal(text, decimals);
|
||||
const typed = clean === "" ? Number.NaN : Number(clean);
|
||||
const clamped = !Number.isNaN(typed) && typed > max;
|
||||
setLimitHit(clamped && maxProp !== undefined);
|
||||
onChange(clamped ? format(max) : clean);
|
||||
}
|
||||
|
||||
function handleInput(event: ChangeEvent<HTMLInputElement>) {
|
||||
if (isDecimal) {
|
||||
handleDecimalInput(event.target.value);
|
||||
return;
|
||||
}
|
||||
const digits = digitsOnly(event.target.value);
|
||||
const clamped = digits !== "" && Number(digits) > max;
|
||||
setLimitHit(clamped && maxProp !== undefined);
|
||||
@@ -102,13 +187,15 @@ export function NumberStepper({
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
maxLength={MAX_DIGITS}
|
||||
inputMode="numeric"
|
||||
maxLength={isDecimal ? MAX_DIGITS + 1 + decimals : MAX_DIGITS}
|
||||
inputMode={isDecimal ? "decimal" : "numeric"}
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
value={isDecimal ? displayValue(value, decimals) : value}
|
||||
onChange={handleInput}
|
||||
aria-describedby={[describedBy, unitId].filter(Boolean).join(" ")}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={[describedBy, unitId, isDecimal ? errorId : undefined]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-invalid={invalid || offGrid || undefined}
|
||||
className="w-20 text-center text-lg font-semibold sm:h-11"
|
||||
/>
|
||||
<button
|
||||
@@ -122,6 +209,16 @@ export function NumberStepper({
|
||||
{unitLabel}
|
||||
</span>
|
||||
</div>
|
||||
{isDecimal ? (
|
||||
<p
|
||||
id={errorId}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-xs text-destructive"
|
||||
>
|
||||
{gridError}
|
||||
</p>
|
||||
) : null}
|
||||
{maxProp !== undefined ? (
|
||||
<p role="status" aria-live="polite" className="text-xs text-muted-foreground empty:hidden">
|
||||
{limitHit ? `Höchstens ${maxProp}` : ""}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { toast } from "sonner";
|
||||
import { FEED_TYPE_LABELS } from "@/lib/feedings/labels";
|
||||
import { hiveFollowUps } from "@/lib/hive/follow-ups";
|
||||
import type { FollowUp } from "@/lib/hive/follow-ups";
|
||||
import type { HiveCatalog, HiveOp, HiveState } from "@/lib/hive/types";
|
||||
|
||||
// Spec section 8: the toast only suggests; the form it links to is prefilled
|
||||
// and the beekeeper books (or not) on their own responsibility.
|
||||
|
||||
const TOAST_DURATION_MS = 12_000;
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
|
||||
|
||||
export interface FollowUpContext {
|
||||
colonyId: string;
|
||||
/** Berlin day of the change; becomes the form's date. */
|
||||
occurredOn: string;
|
||||
navigate: (href: string) => void;
|
||||
/** Whether the colony has a treatment without end date; „Behandlung beenden?“ needs one. */
|
||||
hasOpenTreatment: boolean;
|
||||
}
|
||||
|
||||
/** Link with the query order the spec fixes: colonyId, date, amount, unit, feedType. */
|
||||
function link(path: string, entries: [string, string][]): string {
|
||||
return `${path}?${new URLSearchParams(entries).toString()}`;
|
||||
}
|
||||
|
||||
/** The feeder field's own name („Flüssigfutter“), else the feed type's. */
|
||||
function feedName(followUp: Extract<FollowUp, { kind: "feeding" }>, catalog: HiveCatalog): string {
|
||||
for (const type of catalog.partTypes.values()) {
|
||||
if (type.kind !== "FEEDER") continue;
|
||||
for (const field of type.stateSchema) {
|
||||
if (field.kind === "level" && field.feedType === followUp.feedType && field.unit === followUp.unit) {
|
||||
return field.label;
|
||||
}
|
||||
}
|
||||
}
|
||||
return FEED_TYPE_LABELS[followUp.feedType];
|
||||
}
|
||||
|
||||
function addsEvaporator(ops: HiveOp[], catalog: HiveCatalog): boolean {
|
||||
return ops.some(
|
||||
(op) =>
|
||||
(op.op === "addInsert" || op.op === "addPart") &&
|
||||
catalog.partTypes.get(op.part.partTypeId)?.kind === "EVAPORATOR",
|
||||
);
|
||||
}
|
||||
|
||||
function showOne(followUp: FollowUp, ops: HiveOp[], catalog: HiveCatalog, context: FollowUpContext): void {
|
||||
const { colonyId, occurredOn, navigate } = context;
|
||||
if (followUp.kind === "feeding") {
|
||||
const href = link("/feedings/new", [
|
||||
["colonyId", colonyId],
|
||||
["date", occurredOn],
|
||||
["amount", String(followUp.amount)],
|
||||
["unit", followUp.unit],
|
||||
["feedType", followUp.feedType],
|
||||
]);
|
||||
const amount = `${numberFormat.format(followUp.amount)} ${followUp.unit}`;
|
||||
toast(`${amount} ${feedName(followUp, catalog)} nachgefüllt. Als Fütterung eintragen?`, {
|
||||
duration: TOAST_DURATION_MS,
|
||||
action: { label: "Fütterung eintragen", onClick: () => navigate(href) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (followUp.kind === "treatment") {
|
||||
const href = link("/treatments/new", [
|
||||
["colonyId", colonyId],
|
||||
["date", occurredOn],
|
||||
["amount", String(followUp.amountMl)],
|
||||
["unit", "ml"],
|
||||
]);
|
||||
const verb = addsEvaporator(ops, catalog) ? "eingesetzt" : "nachgefüllt";
|
||||
toast(`Verdunster mit ${numberFormat.format(followUp.amountMl)} ml ${verb}. Als Behandlung eintragen?`, {
|
||||
duration: TOAST_DURATION_MS,
|
||||
action: { label: "Behandlung eintragen", onClick: () => navigate(href) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!context.hasOpenTreatment) return;
|
||||
toast("Behandlung beenden?", {
|
||||
duration: TOAST_DURATION_MS,
|
||||
action: {
|
||||
label: "Behandlungen öffnen",
|
||||
onClick: () => navigate(`/colonies/${encodeURIComponent(colonyId)}#behandlungen`),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** One toast per suggestion of `hiveFollowUps`; nothing is booked here. */
|
||||
export function showHiveFollowUps(
|
||||
ops: HiveOp[],
|
||||
before: HiveState,
|
||||
after: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
context: FollowUpContext,
|
||||
): void {
|
||||
for (const followUp of hiveFollowUps(ops, before, after, catalog)) {
|
||||
showOne(followUp, ops, catalog, context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRef, useState } from "react";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp, HiveState } from "@/lib/hive/types";
|
||||
import { FrameSheet } from "./frame-sheet";
|
||||
import { HiveOutline } from "./hive-outline";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b1", "box", {
|
||||
frames: frames(10).map((frame) =>
|
||||
frame.slot === 4 ? { ...frame, type: "BROOD" as const, name: "Königin" } : frame,
|
||||
),
|
||||
}),
|
||||
part("b2", "box", { frames: frames(10) }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
|
||||
interface HarnessProps {
|
||||
submit: (ops: HiveOp[]) => Promise<unknown>;
|
||||
boxId?: string;
|
||||
slot?: number;
|
||||
}
|
||||
|
||||
function Harness({ submit, boxId = "b1", slot = 4 }: HarnessProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const outlineRef = useRef<HiveOutlineHandle>(null);
|
||||
const [store] = useState(createHiveViewStore);
|
||||
return (
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline
|
||||
ref={outlineRef}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
canEdit
|
||||
announcement=""
|
||||
onEditPart={() => {}}
|
||||
onEditFrame={() => setOpen(true)}
|
||||
/>
|
||||
<FrameSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
boxId={boxId}
|
||||
slot={slot}
|
||||
submit={submit}
|
||||
outlineRef={outlineRef}
|
||||
/>
|
||||
</HiveViewProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const save = () => screen.getByRole("button", { name: "Speichern" });
|
||||
|
||||
describe("FrameSheet", () => {
|
||||
it("is titled with the slot and names the part", () => {
|
||||
render(<Harness submit={vi.fn()} />);
|
||||
expect(screen.getByRole("dialog", { name: "Rähmchen 4" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Zarge 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the ten frame types by their German names with the current one chosen", () => {
|
||||
render(<Harness submit={vi.fn()} />);
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(10);
|
||||
expect(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.BROOD })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.NONE })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Speichern disabled while nothing changed", () => {
|
||||
render(<Harness submit={vi.fn()} />);
|
||||
expect(save()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("saves one setFrame with the chosen type and the trimmed name", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.HONEY }));
|
||||
const name = screen.getByLabelText("Name");
|
||||
await user.clear(name);
|
||||
await user.type(name, " Vorrat ");
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledTimes(1);
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{ op: "setFrame", boxId: "b1", slot: 4, type: "HONEY", name: "Vorrat" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends name null when the name is emptied", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.clear(screen.getByLabelText("Name"));
|
||||
await user.click(save());
|
||||
expect(submit).toHaveBeenCalledWith([
|
||||
{ op: "setFrame", boxId: "b1", slot: 4, type: "BROOD", name: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("limits the name to 40 characters and shows a counter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={vi.fn()} slot={1} />);
|
||||
const name = screen.getByLabelText("Name");
|
||||
expect(name).toHaveAttribute("maxlength", "40");
|
||||
await user.type(name, "x".repeat(45));
|
||||
expect(name).toHaveValue("x".repeat(40));
|
||||
expect(screen.getByText("40 / 40")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Speichern while the request is pending", async () => {
|
||||
let finish: () => void = () => undefined;
|
||||
const submit = vi.fn().mockReturnValue(new Promise<void>((resolve) => (finish = resolve)));
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.POLLEN }));
|
||||
await user.click(save());
|
||||
expect(save()).toBeDisabled();
|
||||
finish();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
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 submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.POLLEN }));
|
||||
await user.click(save());
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Speichern fehlgeschlagen");
|
||||
expect(screen.getByRole("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 submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.POLLEN }));
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("returns focus to the edited frame in the outline, not to the body", async () => {
|
||||
const submit = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={submit} />);
|
||||
await user.click(screen.getByRole("radio", { name: FRAME_TYPE_LABELS.HONEY }));
|
||||
await user.click(save());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /R4 ·/ })).toHaveFocus());
|
||||
expect(document.body).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it("returns focus to the frame also when closed with Escape", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness submit={vi.fn()} />);
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
await waitFor(() => expect(screen.getByRole("treeitem", { name: /R4 ·/ })).toHaveFocus());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SheetFooter } from "@/components/ui/sheet";
|
||||
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { FRAME_TYPES, MAX_TEXT_LENGTH } from "@/lib/hive/types";
|
||||
import type { FrameType, HiveCatalog, HiveState } from "@/lib/hive/types";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import { useSheetSubmit } from "./use-sheet-submit";
|
||||
import type { SubmitOps } from "./use-sheet-submit";
|
||||
|
||||
const FRAME_OPTIONS = choiceOptions(FRAME_TYPE_LABELS, FRAME_TYPES);
|
||||
|
||||
export interface FrameSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
boxId: string;
|
||||
slot: number;
|
||||
/** Sends the ops (source CARD); rejects on failure. */
|
||||
submit: SubmitOps;
|
||||
/** On close, focus returns to this frame's item in the outline. */
|
||||
outlineRef: RefObject<HiveOutlineHandle | null>;
|
||||
}
|
||||
|
||||
interface FrameFormProps {
|
||||
initialType: FrameType;
|
||||
initialName: string;
|
||||
boxId: string;
|
||||
slot: number;
|
||||
submit: SubmitOps;
|
||||
/** Saved, or the hive changed meanwhile (the conflict sheet takes over). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function FrameForm({ initialType, initialName, boxId, slot, submit, onClose }: FrameFormProps) {
|
||||
const [type, setType] = useState<FrameType>(initialType);
|
||||
const [name, setName] = useState(initialName);
|
||||
const { send, pending, error } = useSheetSubmit(submit);
|
||||
const trimmed = name.trim();
|
||||
const changed = type !== initialType || trimmed !== initialName.trim();
|
||||
|
||||
async function save() {
|
||||
const outcome = await send([{ op: "setFrame", boxId, slot, type, name: trimmed === "" ? null : trimmed }]);
|
||||
if (outcome !== "failed") onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 px-4 pb-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (changed && !pending) void save();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={`frame-${boxId}-${slot}-type`} className="text-sm font-medium">
|
||||
Art
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={FRAME_OPTIONS}
|
||||
value={type}
|
||||
onChange={setType}
|
||||
labelledBy={`frame-${boxId}-${slot}-type`}
|
||||
className="grid-cols-2 sm:grid-cols-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={`frame-${boxId}-${slot}-name`} className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id={`frame-${boxId}-${slot}-name`}
|
||||
value={name}
|
||||
maxLength={MAX_TEXT_LENGTH}
|
||||
autoComplete="off"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
aria-describedby={`frame-${boxId}-${slot}-count`}
|
||||
className="h-11"
|
||||
/>
|
||||
<p id={`frame-${boxId}-${slot}-count`} className="text-xs text-muted-foreground">
|
||||
{name.length} / {MAX_TEXT_LENGTH}
|
||||
</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SheetFooter className="p-0">
|
||||
<Button type="submit" size="touch" disabled={!changed || pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Edit one frame of a box: its type and an optional name (spec 9.4). One `setFrame` per save. */
|
||||
export function FrameSheet({ open, onOpenChange, state, catalog, boxId, slot, submit, outlineRef }: FrameSheetProps) {
|
||||
const box = state.stack.find((candidate) => candidate.id === boxId);
|
||||
const frame = box?.frames?.find((candidate) => candidate.slot === slot);
|
||||
|
||||
return (
|
||||
<HiveSheetShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={`Rähmchen ${slot}`}
|
||||
description={partDisplayName(state, catalog, boxId)}
|
||||
finalFocus={() => {
|
||||
outlineRef.current?.focusFrame(boxId, slot);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{frame ? (
|
||||
<FrameForm
|
||||
// A different frame starts a fresh draft.
|
||||
key={`${boxId}#${slot}`}
|
||||
initialType={frame.type}
|
||||
initialName={frame.name ?? ""}
|
||||
boxId={boxId}
|
||||
slot={slot}
|
||||
submit={submit}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
) : (
|
||||
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
|
||||
Dieses Rähmchen gibt es nicht mehr.
|
||||
</p>
|
||||
)}
|
||||
</HiveSheetShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import {
|
||||
fetchHive,
|
||||
fetchHiveAt,
|
||||
fetchHiveHistory,
|
||||
fetchHiveSystems,
|
||||
fetchHiveTemplate,
|
||||
postHiveChange,
|
||||
} from "@/lib/hive/api";
|
||||
import { templateState } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
import { HiveCard } from "./hive-card";
|
||||
import { colonyFixture, historyItem, sampleState, setupView, systemDto } from "./test-fixtures";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("sonner", () => ({ toast: vi.fn() }));
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
fetchHive: vi.fn(),
|
||||
fetchHiveSystems: vi.fn(),
|
||||
fetchHiveTemplate: vi.fn(),
|
||||
fetchHiveHistory: vi.fn(),
|
||||
fetchHiveAt: vi.fn(),
|
||||
postHiveChange: vi.fn(),
|
||||
createHive: vi.fn(),
|
||||
}));
|
||||
let permissions: string[] = [];
|
||||
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
|
||||
}));
|
||||
const connectivity = vi.hoisted(() => ({ value: "online" as "online" | "offline" }));
|
||||
vi.mock("@/lib/offline/connectivity", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/offline/connectivity")>()),
|
||||
useConnectivity: () => connectivity.value,
|
||||
}));
|
||||
|
||||
function renderCard(props: Partial<React.ComponentProps<typeof HiveCard>> = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<HiveCard colonyId="colony-1" colony={colonyFixture()} {...props} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const FRAME_EDIT_OPS: HiveOp[] = [{ op: "setFrame", boxId: "b1", slot: 5, type: "BROOD", name: null }];
|
||||
|
||||
/** Opens Zarge 1 in the outline, picks frame 5 and chooses „Brutwabe“. */
|
||||
async function editFrameFive() {
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Rähmchen 5" });
|
||||
await userEvent.click(within(dialog).getByRole("radio", { name: "Brutwabe" }));
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Speichern" }));
|
||||
}
|
||||
|
||||
describe("HiveCard", () => {
|
||||
beforeEach(() => {
|
||||
permissions = ["colonies:read", "colonies:update"];
|
||||
connectivity.value = "online";
|
||||
vi.mocked(fetchHive).mockReset().mockResolvedValue(setupView());
|
||||
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
|
||||
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
|
||||
vi.mocked(fetchHiveHistory)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
vi.mocked(fetchHiveAt).mockReset();
|
||||
vi.mocked(postHiveChange).mockReset();
|
||||
});
|
||||
|
||||
it("shows the model, the facts line and where the state comes from", async () => {
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Stand: 12.10. aus Durchsicht")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is the right column of the first row and as tall as the card beside it", async () => {
|
||||
const { container } = renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
const root = container.querySelector("[data-slot=card]")!;
|
||||
expect(root).toHaveClass("lg:col-span-2", "min-h-80", "lg:h-auto");
|
||||
expect(root.className).toContain("h-[min(55svh,520px)]");
|
||||
});
|
||||
|
||||
it("offers the hive setup instead of a model when the colony has none", async () => {
|
||||
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("button", { name: "Übernehmen" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows readers only „Noch keine Beute angelegt“", async () => {
|
||||
permissions = ["colonies:read"];
|
||||
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains a failed load, keeps the card and retries on request", async () => {
|
||||
vi.mocked(fetchHive).mockRejectedValueOnce(new ApiError(500, "Fehler", {}));
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Beute konnte nicht geladen werden.");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }));
|
||||
|
||||
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
|
||||
expect(fetchHive).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe("Umbauen", () => {
|
||||
it("links to the editor route", async () => {
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByRole("link", { name: "Umbauen" })).toHaveAttribute("href", "/colonies/colony-1/hive");
|
||||
});
|
||||
|
||||
it("is hidden without colonies:update", async () => {
|
||||
permissions = ["colonies:read"];
|
||||
renderCard();
|
||||
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is hidden for a colony that no longer lives", async () => {
|
||||
renderCard({ colony: colonyFixture({ status: "DISSOLVED" }) });
|
||||
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is disabled offline and says why", async () => {
|
||||
connectivity.value = "offline";
|
||||
renderCard();
|
||||
|
||||
expect(await screen.findByText("Umbauen geht nur mit Netz")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Umbauen" })).toBeDisabled();
|
||||
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the outline in the DOM but visually hidden until „Aufbau als Liste“", async () => {
|
||||
renderCard();
|
||||
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
|
||||
expect(screen.getByRole("tree", { name: "Aufbau der Beute" })).toBeInTheDocument();
|
||||
|
||||
const wrapper = document.getElementById(toggle.getAttribute("aria-controls")!)!;
|
||||
expect(wrapper).toHaveClass("sr-only");
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
await userEvent.click(toggle);
|
||||
|
||||
expect(wrapper).not.toHaveClass("sr-only");
|
||||
expect(toggle).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("keeps the outline from growing the card: it scrolls in an absolutely placed child", async () => {
|
||||
renderCard();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Aufbau als Liste" }));
|
||||
|
||||
const scroll = screen.getByTestId("hive-outline-scroll");
|
||||
expect(scroll).toHaveClass("sm:absolute", "sm:inset-0", "sm:overflow-y-auto");
|
||||
expect(scroll.parentElement).toHaveClass("sm:relative", "sm:w-72");
|
||||
});
|
||||
|
||||
it("points „Aufbau als Liste“ at the outline", async () => {
|
||||
renderCard();
|
||||
const toggle = await screen.findByRole("button", { name: "Aufbau als Liste" });
|
||||
|
||||
const target = document.getElementById(toggle.getAttribute("aria-controls")!);
|
||||
expect(target).toContainElement(screen.getByRole("tree"));
|
||||
});
|
||||
|
||||
describe("editing", () => {
|
||||
it("saves a frame change from the outline as a CARD change", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(setupView());
|
||||
renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await editFrameFive();
|
||||
|
||||
expect(postHiveChange).toHaveBeenCalledWith(
|
||||
"colony-1",
|
||||
{ occurredOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), source: "CARD", ops: FRAME_EDIT_OPS },
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not open a frame sheet for a reader", async () => {
|
||||
permissions = ["colonies:read"];
|
||||
renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge 1/ }));
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
|
||||
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("in inspection mode keeps a draft and reports the ops instead of saving", async () => {
|
||||
const onOpsChange = vi.fn();
|
||||
renderCard({ mode: "inspection", onOpsChange });
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await editFrameFive();
|
||||
|
||||
expect(onOpsChange).toHaveBeenCalledWith(FRAME_EDIT_OPS);
|
||||
expect(postHiveChange).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText(/7 Brutwaben/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: "Umbauen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the conflict sheet when the hive changed meanwhile", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(
|
||||
new ApiError(409, "Konflikt", {
|
||||
code: "HIVE_CONFLICT",
|
||||
opIndex: 0,
|
||||
reason: "x",
|
||||
current: { version: 9, state: sampleState() },
|
||||
}),
|
||||
);
|
||||
renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await editFrameFive();
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verlauf", () => {
|
||||
it("shows the state of a past day read-only and returns to today", async () => {
|
||||
const past = { ...sampleState(), stack: sampleState().stack.slice(0, 2) };
|
||||
vi.mocked(fetchHiveAt).mockResolvedValue({
|
||||
version: 2,
|
||||
state: past,
|
||||
facts: {} as never,
|
||||
occurredOn: "2026-08-12",
|
||||
});
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({
|
||||
items: [historyItem({ occurredOn: "2026-08-12", createdAt: "2026-08-12T10:00:00.000Z" })],
|
||||
nextCursor: null,
|
||||
});
|
||||
renderCard();
|
||||
await screen.findByRole("img", { name: /Beute mit/ });
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Verlauf" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Verlauf" });
|
||||
await userEvent.click(await within(dialog).findByRole("button", { name: /12\.08\./ }));
|
||||
|
||||
expect(await screen.findByText(/Stand vom 12\.08\./)).toBeInTheDocument();
|
||||
expect(screen.getByText("1 Zarge · 3 Brutwaben · Bausperre drin")).toBeInTheDocument();
|
||||
// Read-only: a frame cannot be edited while a past state is shown.
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /Zarge/ }));
|
||||
await userEvent.click(screen.getByRole("treeitem", { name: /R5 · Mittelwand/ }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zurück zu heute" }));
|
||||
|
||||
expect(screen.queryByText(/Stand vom/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Verlauf" })).toHaveFocus();
|
||||
expect(screen.getByText("2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { History, ListTree } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { fetchHive, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
|
||||
import { hiveReasonText } from "@/lib/hive/reason-text";
|
||||
import { describeHiveOps } from "@/lib/hive/describe";
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
import type { HiveCatalog, HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
|
||||
import { useConnectivity } from "@/lib/offline/connectivity";
|
||||
import { FrameSheet } from "./frame-sheet";
|
||||
import { HiveConflictSheet } from "./hive-conflict-sheet";
|
||||
import { HiveFactsLine } from "./hive-facts-line";
|
||||
import { HiveHistory, HiveVersionBar, useHiveHistory } from "./hive-history";
|
||||
import { HiveInit } from "./hive-init";
|
||||
import { HiveOutline } from "./hive-outline";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import { HiveVisualSlot } from "./hive-visual-slot";
|
||||
import { PartSheet } from "./part-sheet";
|
||||
import { useHiveMutation } from "./use-hive-mutation";
|
||||
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
|
||||
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
|
||||
import { HiveDraftError } from "./use-sheet-submit";
|
||||
import type { SubmitOps } from "./use-sheet-submit";
|
||||
|
||||
const LOAD_FAILED_TEXT = "Beute konnte nicht geladen werden.";
|
||||
const OFFLINE_REBUILD_HINT = "Umbauen geht nur mit Netz";
|
||||
|
||||
export interface HiveCardProps {
|
||||
colonyId: string;
|
||||
colony: Colony;
|
||||
/** "inspection" keeps a local draft and reports the ops instead of saving (inspection form). */
|
||||
mode?: "view" | "inspection";
|
||||
onOpsChange?: (ops: HiveOp[]) => void;
|
||||
}
|
||||
|
||||
/** Colony page card: model, facts line, outline, history (spec 9). Fixed height below lg, as tall as „Stammdaten“ above. */
|
||||
export function HiveCard(props: HiveCardProps) {
|
||||
const [store] = useState(createHiveViewStore);
|
||||
return (
|
||||
<HiveViewProvider store={store}>
|
||||
<Card className="h-[min(55svh,520px)] min-h-80 lg:col-span-2 lg:h-auto lg:min-h-0">
|
||||
<CardHeader>
|
||||
<CardTitle>Beute</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
||||
<HiveCardContent {...props} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</HiveViewProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange }: HiveCardProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const hive = useQuery({ queryKey: hiveKeys.detail(colonyId), queryFn: () => fetchHive(colonyId) });
|
||||
const systems = useHiveSystems();
|
||||
const catalog = useSystemCatalog(systems.data, hive.data?.systemId);
|
||||
const canEdit = hasPermission("colonies:update") && isLivingColony(colony);
|
||||
|
||||
if (hiveErrorCode(hive.error) === "HIVE_NOT_FOUND") {
|
||||
return <HiveInit colonyId={colonyId} colony={colony} canEdit={canEdit} />;
|
||||
}
|
||||
if (hive.isError || systems.isError) {
|
||||
return (
|
||||
<div role="alert" className="flex flex-1 flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-sm">{LOAD_FAILED_TEXT}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
onClick={() => {
|
||||
if (hive.isError) void hive.refetch();
|
||||
if (systems.isError) void systems.refetch();
|
||||
}}
|
||||
>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!hive.data || !catalog) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Beute wird geladen"
|
||||
className="min-h-40 flex-1 rounded-lg bg-muted motion-safe:animate-pulse"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<HiveCardBody
|
||||
colonyId={colonyId}
|
||||
setup={hive.data}
|
||||
catalog={catalog}
|
||||
canEdit={canEdit}
|
||||
mode={mode}
|
||||
onOpsChange={onOpsChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FrameTarget {
|
||||
boxId: string;
|
||||
slot: number;
|
||||
}
|
||||
|
||||
interface PartTarget {
|
||||
id: string;
|
||||
evaporator: boolean;
|
||||
}
|
||||
|
||||
interface PastState {
|
||||
state: HiveState;
|
||||
occurredOn: string;
|
||||
}
|
||||
|
||||
function findPartKind(state: HiveState, catalog: HiveCatalog, id: string) {
|
||||
for (const part of state.stack) {
|
||||
const hit = part.id === id ? part : part.inserts?.find((insert) => insert.id === id);
|
||||
if (hit) return catalog.partTypes.get(hit.partTypeId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface HiveCardBodyProps {
|
||||
colonyId: string;
|
||||
setup: HiveSetupView;
|
||||
catalog: HiveCatalog;
|
||||
canEdit: boolean;
|
||||
mode: "view" | "inspection";
|
||||
onOpsChange?: (ops: HiveOp[]) => void;
|
||||
}
|
||||
|
||||
function HiveCardBody({ colonyId, setup, catalog, canEdit, mode, onOpsChange }: HiveCardBodyProps) {
|
||||
const view = useHiveView();
|
||||
const connectivity = useConnectivity();
|
||||
const mutation = useHiveMutation(colonyId, catalog);
|
||||
const history = useHiveHistory(colonyId);
|
||||
const outlineRef = useRef<HiveOutlineHandle>(null);
|
||||
const historyButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const outlineId = useId();
|
||||
|
||||
const [draftOps, setDraftOps] = useState<HiveOp[]>([]);
|
||||
const [past, setPast] = useState<PastState | null>(null);
|
||||
const [showOutline, setShowOutline] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [frameTarget, setFrameTarget] = useState<FrameTarget | null>(null);
|
||||
const [frameOpen, setFrameOpen] = useState(false);
|
||||
const [partTarget, setPartTarget] = useState<PartTarget | null>(null);
|
||||
const [partOpen, setPartOpen] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
|
||||
const isInspection = mode === "inspection";
|
||||
// The state edits apply to: the loaded one, plus the local draft in inspection mode.
|
||||
const workingState = useMemo(() => {
|
||||
if (!isInspection || draftOps.length === 0) return setup.state;
|
||||
const applied = applyHiveOps(setup.state, draftOps, catalog);
|
||||
return applied.ok ? applied.state : setup.state;
|
||||
}, [isInspection, draftOps, setup.state, catalog]);
|
||||
const shownState = past?.state ?? workingState;
|
||||
const editable = canEdit && past === null;
|
||||
|
||||
const facts = useMemo(() => deriveHiveFacts(shownState, catalog), [shownState, catalog]);
|
||||
const layout = useMemo(() => hiveLayout(shownState, catalog), [shownState, catalog]);
|
||||
const lastChange = history.data?.pages[0]?.items[0];
|
||||
|
||||
const submit: SubmitOps = async (ops) => {
|
||||
const before = workingState;
|
||||
if (isInspection) {
|
||||
const next = [...draftOps, ...ops];
|
||||
const applied = applyHiveOps(setup.state, next, catalog);
|
||||
if (!applied.ok) throw new HiveDraftError(hiveReasonText(applied.reason));
|
||||
setDraftOps(next);
|
||||
onOpsChange?.(next);
|
||||
} else {
|
||||
await mutation.submit(ops, "CARD");
|
||||
}
|
||||
setAnnouncement(`${describeHiveOps(ops, before, catalog)}.`);
|
||||
};
|
||||
|
||||
const partName = (id: string) => partDisplayName(shownState, catalog, id);
|
||||
|
||||
function editFrame(boxId: string, slot: number) {
|
||||
if (!editable) return;
|
||||
setFrameTarget({ boxId, slot });
|
||||
setFrameOpen(true);
|
||||
}
|
||||
|
||||
function editPart(id: string) {
|
||||
if (!editable) return;
|
||||
// The outline's „Verdunster einsetzen“ action reports its box.
|
||||
const evaporator = findPartKind(workingState, catalog, id)?.kind === "BOX";
|
||||
setPartTarget({ id, evaporator });
|
||||
setPartOpen(true);
|
||||
}
|
||||
|
||||
function selectPart(id: string) {
|
||||
const type = findPartKind(shownState, catalog, id);
|
||||
if (type?.kind === "BOX") {
|
||||
view.openBox(id);
|
||||
return;
|
||||
}
|
||||
if (type && (type.stateSchema.length > 0 || type.kind === "FLOOR")) editPart(id);
|
||||
}
|
||||
|
||||
const showRebuild = canEdit && !isInspection;
|
||||
const offline = connectivity === "offline";
|
||||
|
||||
return (
|
||||
<>
|
||||
{past ? <HiveVersionBar occurredOn={past.occurredOn} onBack={() => {
|
||||
setPast(null);
|
||||
historyButtonRef.current?.focus();
|
||||
}} /> : null}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 sm:flex-row">
|
||||
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
|
||||
<div className="absolute inset-0">
|
||||
<HiveVisualSlot
|
||||
layout={layout}
|
||||
catalog={catalog}
|
||||
partName={partName}
|
||||
onSelectPart={selectPart}
|
||||
onSelectFrame={editFrame}
|
||||
/>
|
||||
</div>
|
||||
{view.openBoxId ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="touch"
|
||||
className="absolute start-2 top-2"
|
||||
onClick={() => view.openBox(null)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
id={outlineId}
|
||||
className={
|
||||
showOutline
|
||||
? "max-h-40 min-h-0 overflow-y-auto sm:relative sm:max-h-none sm:w-72 sm:shrink-0 sm:overflow-visible"
|
||||
: "sr-only"
|
||||
}
|
||||
>
|
||||
<div className="sm:absolute sm:inset-0 sm:overflow-y-auto" data-testid="hive-outline-scroll">
|
||||
<HiveOutline
|
||||
ref={outlineRef}
|
||||
state={shownState}
|
||||
catalog={catalog}
|
||||
canEdit={editable}
|
||||
announcement={announcement}
|
||||
onEditPart={editPart}
|
||||
onEditFrame={editFrame}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<HiveFactsLine facts={facts} lastChange={past ? null : lastChange} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{showRebuild && !offline ? (
|
||||
<Link
|
||||
href={`/colonies/${encodeURIComponent(colonyId)}/hive`}
|
||||
className={buttonVariants({ size: "touch" })}
|
||||
>
|
||||
Umbauen
|
||||
</Link>
|
||||
) : null}
|
||||
{showRebuild && offline ? (
|
||||
<Button size="touch" disabled aria-describedby="hive-rebuild-hint">
|
||||
Umbauen
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
aria-pressed={showOutline}
|
||||
aria-controls={outlineId}
|
||||
onClick={() => setShowOutline(!showOutline)}
|
||||
>
|
||||
<ListTree aria-hidden />
|
||||
Aufbau als Liste
|
||||
</Button>
|
||||
<Button ref={historyButtonRef} type="button" variant="outline" size="touch" onClick={() => setHistoryOpen(true)}>
|
||||
<History aria-hidden />
|
||||
Verlauf
|
||||
</Button>
|
||||
</div>
|
||||
{showRebuild && offline ? (
|
||||
<p id="hive-rebuild-hint" className="text-xs text-muted-foreground">
|
||||
{OFFLINE_REBUILD_HINT}
|
||||
</p>
|
||||
) : null}
|
||||
<HiveSheetShell open={historyOpen} onOpenChange={setHistoryOpen} title="Verlauf">
|
||||
<HiveHistory
|
||||
colonyId={colonyId}
|
||||
state={setup.state}
|
||||
catalog={catalog}
|
||||
onShowVersion={(state, occurredOn) => {
|
||||
setPast({ state, occurredOn });
|
||||
setHistoryOpen(false);
|
||||
}}
|
||||
/>
|
||||
</HiveSheetShell>
|
||||
{frameTarget ? (
|
||||
<FrameSheet
|
||||
open={frameOpen}
|
||||
onOpenChange={setFrameOpen}
|
||||
state={workingState}
|
||||
catalog={catalog}
|
||||
boxId={frameTarget.boxId}
|
||||
slot={frameTarget.slot}
|
||||
submit={submit}
|
||||
outlineRef={outlineRef}
|
||||
/>
|
||||
) : null}
|
||||
{partTarget ? (
|
||||
<PartSheet
|
||||
open={partOpen}
|
||||
onOpenChange={setPartOpen}
|
||||
state={workingState}
|
||||
catalog={catalog}
|
||||
partId={partTarget.id}
|
||||
startWithEvaporator={partTarget.evaporator}
|
||||
submit={submit}
|
||||
outlineRef={outlineRef}
|
||||
/>
|
||||
) : null}
|
||||
<HiveConflictSheet conflict={mutation.conflict} catalog={catalog} onResolve={mutation.resolveConflict} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp, HiveState } from "@/lib/hive/types";
|
||||
import { HiveConflictSheet } from "./hive-conflict-sheet";
|
||||
import type { HiveConflict } from "./use-hive-mutation";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const current: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b", "box", { frames: frames(10) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 1, fondantKg: 0 } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }];
|
||||
|
||||
const conflictOf = (state: HiveState, userOps: HiveOp[] = ops): HiveConflict => ({
|
||||
current: { version: 5, state },
|
||||
ops: userOps,
|
||||
preview: applyHiveOps(state, userOps, catalog),
|
||||
});
|
||||
|
||||
describe("HiveConflictSheet", () => {
|
||||
it("renders nothing without a conflict", () => {
|
||||
render(<HiveConflictSheet conflict={null} catalog={catalog} onResolve={vi.fn()} />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the preview of the own changes on the current state", () => {
|
||||
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={vi.fn()} />);
|
||||
expect(screen.getByRole("dialog", { name: "Die Beute wurde inzwischen geändert" })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Flüssigfutter 3 l/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Erneut speichern" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("names the operation and the reason when the changes no longer fit", () => {
|
||||
const gone: HiveState = { ...current, stack: current.stack.filter((p) => p.id !== "fe") };
|
||||
render(<HiveConflictSheet conflict={conflictOf(gone)} catalog={catalog} onResolve={vi.fn()} />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("passt nicht mehr");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("gibt es nicht mehr");
|
||||
expect(screen.getByRole("button", { name: "Erneut speichern" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("resolves with retry and with discard", async () => {
|
||||
const onResolve = vi.fn().mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={onResolve} />);
|
||||
await user.click(screen.getByRole("button", { name: "Erneut speichern" }));
|
||||
expect(onResolve).toHaveBeenLastCalledWith("retry");
|
||||
await user.click(screen.getByRole("button", { name: "Verwerfen" }));
|
||||
expect(onResolve).toHaveBeenLastCalledWith("discard");
|
||||
});
|
||||
|
||||
it("shows a failed retry inline", async () => {
|
||||
const onResolve = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const user = userEvent.setup();
|
||||
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={onResolve} />);
|
||||
await user.click(screen.getByRole("button", { name: "Erneut speichern" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Speichern fehlgeschlagen");
|
||||
});
|
||||
|
||||
it("ignores closing with Escape while a request is running", async () => {
|
||||
let finish: () => void = () => undefined;
|
||||
const onResolve = vi.fn().mockReturnValue(new Promise<void>((r) => (finish = r)));
|
||||
const user = userEvent.setup();
|
||||
render(<HiveConflictSheet conflict={conflictOf(current)} catalog={catalog} onResolve={onResolve} />);
|
||||
await user.click(screen.getByRole("button", { name: "Erneut speichern" }));
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onResolve).toHaveBeenCalledTimes(1);
|
||||
expect(onResolve).toHaveBeenCalledWith("retry");
|
||||
finish();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetFooter } from "@/components/ui/sheet";
|
||||
import { hiveReasonText } from "@/lib/hive/reason-text";
|
||||
import { describeHiveOps } from "@/lib/hive/describe";
|
||||
import type { HiveCatalog } from "@/lib/hive/types";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import type { HiveConflict } from "./use-hive-mutation";
|
||||
import { SAVE_FAILED_TEXT } from "./use-sheet-submit";
|
||||
|
||||
export interface HiveConflictSheetProps {
|
||||
conflict: HiveConflict | null;
|
||||
catalog: HiveCatalog;
|
||||
onResolve: (action: "retry" | "discard") => Promise<void>;
|
||||
}
|
||||
|
||||
/** Spec section 11: the hive changed meanwhile; the own ops are shown on the current state. */
|
||||
export function HiveConflictSheet({ conflict, catalog, onResolve }: HiveConflictSheetProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function resolve(action: "retry" | "discard") {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onResolve(action);
|
||||
} catch {
|
||||
setError(SAVE_FAILED_TEXT);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const preview = conflict?.preview;
|
||||
const failedOp = conflict && preview && !preview.ok ? conflict.ops.slice(preview.opIndex, preview.opIndex + 1) : [];
|
||||
|
||||
return (
|
||||
<HiveSheetShell
|
||||
open={conflict !== null}
|
||||
onOpenChange={(open) => {
|
||||
// Closing counts as discard, but not while a request is running.
|
||||
if (!open && !busy) void resolve("discard");
|
||||
}}
|
||||
title="Die Beute wurde inzwischen geändert"
|
||||
description="Jemand anderes hat den Aufbau verändert. Deine Änderung wurde noch nicht gespeichert."
|
||||
>
|
||||
{conflict && preview ? (
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
{preview.ok ? (
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Deine Änderung auf dem neuen Stand: </span>
|
||||
{describeHiveOps(conflict.ops, conflict.current.state, catalog)}
|
||||
</p>
|
||||
) : (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Deine Änderung passt nicht mehr:{" "}
|
||||
{describeHiveOps(failedOp, conflict.current.state, catalog) || "Schritt " + (preview.opIndex + 1)}.
|
||||
<span className="mt-1 block text-xs text-muted-foreground">{hiveReasonText(preview.reason)}</span>
|
||||
</p>
|
||||
)}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SheetFooter className="p-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
disabled={!preview.ok || busy}
|
||||
onClick={() => void resolve("retry")}
|
||||
>
|
||||
Erneut speichern
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="touch" disabled={busy} onClick={() => void resolve("discard")}>
|
||||
Verwerfen
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
) : null}
|
||||
</HiveSheetShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { hiveFactsText, shortGermanDay, sourceLabel } from "@/lib/hive/summary";
|
||||
import type { HiveChangeSource, HiveFacts } from "@/lib/hive/types";
|
||||
|
||||
export interface HiveFactsLineProps {
|
||||
facts: HiveFacts;
|
||||
/** The latest change; omitted while a past state is shown. */
|
||||
lastChange?: { occurredOn: string; source: HiveChangeSource } | null;
|
||||
}
|
||||
|
||||
/** „2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin“ and „Stand: 12.10. aus Durchsicht“ (spec 9.4). */
|
||||
export function HiveFactsLine({ facts, lastChange }: HiveFactsLineProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">{hiveFactsText(facts)}</p>
|
||||
{lastChange ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Stand: {shortGermanDay(lastChange.occurredOn)} aus {sourceLabel(lastChange.source)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fetchHiveAt, fetchHiveHistory } from "@/lib/hive/api";
|
||||
import { inlineCatalog } from "@/lib/hive/test-utils";
|
||||
import { HiveHistory, HiveVersionBar } from "./hive-history";
|
||||
import { historyItem, sampleState } from "./test-fixtures";
|
||||
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
fetchHiveHistory: vi.fn(),
|
||||
fetchHiveAt: vi.fn(),
|
||||
}));
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
function renderHistory(onShowVersion = vi.fn()) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<HiveHistory colonyId="colony-1" state={sampleState()} catalog={catalog} onShowVersion={onShowVersion} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onShowVersion;
|
||||
}
|
||||
|
||||
describe("HiveHistory", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchHiveHistory).mockReset();
|
||||
vi.mocked(fetchHiveAt).mockReset();
|
||||
});
|
||||
|
||||
it("lists a change with day, source and the text from its operations", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
renderHistory();
|
||||
|
||||
const row = await screen.findByRole("button", { name: /12\.10\./ });
|
||||
expect(row).toHaveTextContent("Durchsicht");
|
||||
expect(row).toHaveTextContent("Flüssigfutter 3 l");
|
||||
});
|
||||
|
||||
it("marks a change entered after the fact with „nachgetragen am“", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({
|
||||
items: [historyItem({ occurredOn: "2026-10-10", createdAt: "2026-10-15T09:00:00.000Z" })],
|
||||
nextCursor: null,
|
||||
});
|
||||
renderHistory();
|
||||
|
||||
expect(await screen.findByText("nachgetragen am 15.10.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not mark a change entered on its own day", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
renderHistory();
|
||||
|
||||
await screen.findByRole("button", { name: /12\.10\./ });
|
||||
expect(screen.queryByText(/nachgetragen/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads the state of that day when a row is chosen", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
const at = { version: 3, state: sampleState(), facts: {} as never, occurredOn: "2026-10-12" };
|
||||
vi.mocked(fetchHiveAt).mockResolvedValue(at);
|
||||
const onShowVersion = renderHistory();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /12\.10\./ }));
|
||||
|
||||
expect(fetchHiveAt).toHaveBeenCalledWith("colony-1", "2026-10-12");
|
||||
expect(onShowVersion).toHaveBeenCalledWith(at.state, "2026-10-12");
|
||||
});
|
||||
|
||||
it("says so when the state of that day cannot be loaded", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [historyItem()], nextCursor: null });
|
||||
vi.mocked(fetchHiveAt).mockRejectedValue(new Error("offline"));
|
||||
const onShowVersion = renderHistory();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /12\.10\./ }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Stand konnte nicht geladen werden.");
|
||||
expect(onShowVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads the next page with the cursor", async () => {
|
||||
vi.mocked(fetchHiveHistory)
|
||||
.mockResolvedValueOnce({ items: [historyItem()], nextCursor: "cur-1" })
|
||||
.mockResolvedValueOnce({
|
||||
items: [historyItem({ id: "c0", version: 2, occurredOn: "2026-09-01", source: "INIT", ops: [] })],
|
||||
nextCursor: null,
|
||||
});
|
||||
renderHistory();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Mehr laden" }));
|
||||
|
||||
expect(fetchHiveHistory).toHaveBeenLastCalledWith("colony-1", "cur-1");
|
||||
const row = await screen.findByRole("button", { name: /01\.09\./ });
|
||||
expect(row).toHaveTextContent("Beute angelegt");
|
||||
expect(screen.queryByRole("button", { name: "Mehr laden" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says when nothing was changed yet", async () => {
|
||||
vi.mocked(fetchHiveHistory).mockResolvedValue({ items: [], nextCursor: null });
|
||||
renderHistory();
|
||||
|
||||
expect(await screen.findByText("Noch keine Änderungen.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiveVersionBar", () => {
|
||||
it("shows the day and a way back to today", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<HiveVersionBar occurredOn="2026-08-12" onBack={onBack} />);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent(/Stand vom 12\.08\./);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Zurück zu heute" }));
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { berlinCalendarDay } from "@/lib/dates";
|
||||
import { fetchHiveAt, fetchHiveHistory, hiveKeys } from "@/lib/hive/api";
|
||||
import { describeHiveOps } from "@/lib/hive/describe";
|
||||
import { shortGermanDay, sourceLabel } from "@/lib/hive/summary";
|
||||
import type { HiveCatalog, HiveChangeListItem, HiveOp, HiveState } from "@/lib/hive/types";
|
||||
|
||||
const LOAD_FAILED_TEXT = "Stand konnte nicht geladen werden.";
|
||||
|
||||
/** History pages of a colony; the card reads the first item for „Stand: …“, the list reads all pages. */
|
||||
export function useHiveHistory(colonyId: string) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...hiveKeys.history(colonyId), "pages"],
|
||||
queryFn: ({ pageParam }) => fetchHiveHistory(colonyId, pageParam),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (page) => page.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function textOf(item: HiveChangeListItem, state: HiveState, catalog: HiveCatalog): string {
|
||||
const ops = Array.isArray(item.ops) ? (item.ops as HiveOp[]) : [];
|
||||
// Items carry no state before; the current state names parts that still exist.
|
||||
const text = describeHiveOps(ops, state, catalog);
|
||||
if (text) return text;
|
||||
return item.source === "INIT" ? "Beute angelegt" : "Änderung";
|
||||
}
|
||||
|
||||
export interface HiveHistoryProps {
|
||||
colonyId: string;
|
||||
/** The current state, only used to name parts in the texts. */
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
/** The state after the chosen change (read-only in the card). */
|
||||
onShowVersion: (state: HiveState, occurredOn: string) => void;
|
||||
}
|
||||
|
||||
/** Paged list of the hive's changes (spec 9.10). */
|
||||
export function HiveHistory({ colonyId, state, catalog, onShowVersion }: HiveHistoryProps) {
|
||||
const history = useHiveHistory(colonyId);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
|
||||
|
||||
async function show(item: HiveChangeListItem) {
|
||||
setLoadingId(item.id);
|
||||
setError(null);
|
||||
try {
|
||||
const view = await fetchHiveAt(colonyId, item.occurredOn);
|
||||
onShowVersion(view.state, view.occurredOn);
|
||||
} catch {
|
||||
setError(LOAD_FAILED_TEXT);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (history.isLoading) {
|
||||
return <p className="px-4 pb-4 text-sm text-muted-foreground">Verlauf wird geladen…</p>;
|
||||
}
|
||||
if (history.isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2 px-4 pb-4">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Verlauf konnte nicht geladen werden.
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="touch" onClick={() => void history.refetch()}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return <p className="px-4 pb-4 text-sm text-muted-foreground">Noch keine Änderungen.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const enteredOn = berlinCalendarDay(item.createdAt);
|
||||
const isLate = item.occurredOn < enteredOn;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingId !== null}
|
||||
onClick={() => void show(item)}
|
||||
className="flex min-h-11 w-full flex-col items-start justify-center rounded-lg px-3 py-1.5 text-start text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-60"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{shortGermanDay(item.occurredOn)} · {sourceLabel(item.source)}
|
||||
</span>
|
||||
<span>{textOf(item, state, catalog)}</span>
|
||||
{isLate ? (
|
||||
<span className="text-xs text-muted-foreground">nachgetragen am {shortGermanDay(enteredOn)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{history.hasNextPage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
disabled={history.isFetchingNextPage}
|
||||
onClick={() => void history.fetchNextPage()}
|
||||
>
|
||||
Mehr laden
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface HiveVersionBarProps {
|
||||
occurredOn: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/** „Stand vom 12.08. · Zurück zu heute“: shown while a past state is displayed read-only. */
|
||||
export function HiveVersionBar({ occurredOn, onBack }: HiveVersionBarProps) {
|
||||
return (
|
||||
<div role="status" className="flex flex-wrap items-center gap-2 rounded-lg bg-muted px-3 py-1 text-sm">
|
||||
<span>Stand vom {shortGermanDay(occurredOn)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<Button type="button" variant="link" size="touch" onClick={onBack}>
|
||||
Zurück zu heute
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { createHive, fetchHive, fetchHiveSystems, fetchHiveTemplate } from "@/lib/hive/api";
|
||||
import { frames, part, templateState } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import { HiveInit } from "./hive-init";
|
||||
import { colonyFixture, setupView, systemDto } from "./test-fixtures";
|
||||
|
||||
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn() }));
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
fetchHiveSystems: vi.fn(),
|
||||
fetchHiveTemplate: vi.fn(),
|
||||
fetchHive: vi.fn(),
|
||||
createHive: vi.fn(),
|
||||
}));
|
||||
let permissions: string[] = [];
|
||||
vi.mock("@/lib/auth/auth-context", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/auth/auth-context")>()),
|
||||
useAuth: () => ({ hasPermission: (key: string) => permissions.includes(key) }),
|
||||
}));
|
||||
|
||||
function renderInit(canEdit = true) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<HiveInit colonyId="colony-1" colony={colonyFixture()} canEdit={canEdit} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("HiveInit", () => {
|
||||
beforeEach(() => {
|
||||
permissions = ["colonies:update"];
|
||||
vi.mocked(fetchHiveSystems).mockReset().mockResolvedValue([systemDto()]);
|
||||
vi.mocked(fetchHiveTemplate).mockReset().mockResolvedValue(templateState());
|
||||
vi.mocked(fetchHive).mockReset();
|
||||
vi.mocked(createHive).mockReset();
|
||||
vi.mocked(fetchColonies)
|
||||
.mockReset()
|
||||
.mockResolvedValue([
|
||||
colonyFixture(),
|
||||
colonyFixture({ id: "colony-2", code: "V-02", apiaryId: "apiary-2" }),
|
||||
colonyFixture({ id: "colony-3", code: "V-03", apiaryId: "apiary-1" }),
|
||||
colonyFixture({ id: "colony-4", code: "V-04", status: "DISSOLVED" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows readers only the text, without loading the template", async () => {
|
||||
permissions = [];
|
||||
renderInit(false);
|
||||
|
||||
expect(await screen.findByText("Noch keine Beute angelegt")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Übernehmen" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Wie anderes Volk" })).not.toBeInTheDocument();
|
||||
expect(fetchHiveTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("previews the template of the first active system and creates it on „Übernehmen“", async () => {
|
||||
vi.mocked(createHive).mockResolvedValue(setupView(templateState()));
|
||||
const client = renderInit();
|
||||
|
||||
expect(await screen.findByRole("img", { name: /Beute mit Zarge/ })).toBeInTheDocument();
|
||||
expect(fetchHiveTemplate).toHaveBeenCalledWith("sys");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
expect(createHive).toHaveBeenCalledWith(
|
||||
"colony-1",
|
||||
{ systemId: "sys", state: templateState(), occurredOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
|
||||
expect.any(String),
|
||||
);
|
||||
await vi.waitFor(() => expect(client.getQueryData(["colonies", "colony-1", "hive"])).toBeDefined());
|
||||
});
|
||||
|
||||
it("explains a missing template and links to the settings only for hive-systems:manage", async () => {
|
||||
vi.mocked(fetchHiveTemplate).mockRejectedValue(
|
||||
new ApiError(409, "Vorlage fehlt", { code: "TEMPLATE_NOT_SET" }),
|
||||
);
|
||||
permissions = ["colonies:update", "hive-systems:manage"];
|
||||
renderInit();
|
||||
|
||||
expect(await screen.findByText("Für dieses Beutensystem fehlt eine Vorlage.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /Einstellungen/ })).toHaveAttribute("href", "/settings/hive-systems");
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows no settings link without the permission", async () => {
|
||||
vi.mocked(fetchHiveTemplate).mockRejectedValue(
|
||||
new ApiError(409, "Vorlage fehlt", { code: "TEMPLATE_NOT_SET" }),
|
||||
);
|
||||
renderInit();
|
||||
|
||||
await screen.findByText("Für dieses Beutensystem fehlt eine Vorlage.");
|
||||
expect(screen.queryByRole("link", { name: /Einstellungen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("copies another colony: same apiary first, empty combs, fresh ids", async () => {
|
||||
const other: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b", "box", { frames: frames(10, "BROOD") }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
vi.mocked(fetchHive).mockResolvedValue(setupView(other, "colony-3"));
|
||||
vi.mocked(createHive).mockResolvedValue(setupView(other));
|
||||
renderInit();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wie anderes Volk" }));
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
const options = await screen.findAllByRole("option");
|
||||
// The colony itself and ended colonies are not offered; V-03 shares the apiary and comes first.
|
||||
expect(options.map((option) => option.textContent)).toEqual(["V-03", "V-02"]);
|
||||
await userEvent.click(options[0]);
|
||||
|
||||
expect(await screen.findByRole("img", { name: /Beute mit/ })).toBeInTheDocument();
|
||||
expect(fetchHive).toHaveBeenCalledWith("colony-3");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
const input = vi.mocked(createHive).mock.calls[0][1];
|
||||
const box = input.state.stack[1];
|
||||
expect(box.frames?.every((frame) => frame.type === "EMPTY_COMB")).toBe(true);
|
||||
expect(input.state.stack.map((p) => p.id)).not.toContain("b");
|
||||
});
|
||||
|
||||
it("tells when the other colony has no hive yet", async () => {
|
||||
vi.mocked(fetchHive).mockRejectedValue(new ApiError(404, "Nicht gefunden", { code: "HIVE_NOT_FOUND" }));
|
||||
renderInit();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Wie anderes Volk" }));
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
await userEvent.click((await screen.findAllByRole("option"))[0]);
|
||||
|
||||
expect(await screen.findByText("Dieses Volk hat noch keine Beute.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows an error when creating fails and keeps the button", async () => {
|
||||
vi.mocked(createHive).mockRejectedValue(new ApiError(500, "Fehler", {}));
|
||||
renderInit();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Beute konnte nicht angelegt werden.");
|
||||
expect(screen.getByRole("button", { name: "Übernehmen" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("reuses the idempotency key when the same proposal is pressed again after an error", async () => {
|
||||
vi.mocked(createHive).mockRejectedValueOnce(new ApiError(500, "Fehler", {})).mockResolvedValue(setupView());
|
||||
renderInit();
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Übernehmen" }));
|
||||
await screen.findByRole("alert");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Übernehmen" }));
|
||||
|
||||
const keys = vi.mocked(createHive).mock.calls.map((call) => call[2]);
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[0]).toBe(keys[1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Field } from "@/components/form/field";
|
||||
import { RecordSelect } from "@/components/form/record-select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchColonies } from "@/lib/colonies/api";
|
||||
import { colonyOptionLabel } from "@/lib/colonies/labels";
|
||||
import { isLivingColony } from "@/lib/colonies/status";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
import { berlinCalendarDay } from "@/lib/dates";
|
||||
import { createHive, fetchHive, fetchHiveTemplate, hiveErrorCode, hiveKeys } from "@/lib/hive/api";
|
||||
import { copyHiveFrom } from "@/lib/hive/copy";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import type { HiveCatalog, HiveState } from "@/lib/hive/types";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import { HiveSvg } from "./hive-svg";
|
||||
import { useHiveSystems, useSystemCatalog } from "./use-hive-systems";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const NO_TEMPLATE_TEXT = "Für dieses Beutensystem fehlt eine Vorlage.";
|
||||
const OTHER_WITHOUT_HIVE_TEXT = "Dieses Volk hat noch keine Beute.";
|
||||
const CREATE_FAILED_TEXT = "Beute konnte nicht angelegt werden.";
|
||||
const LOAD_FAILED_TEXT = "Vorschau konnte nicht geladen werden.";
|
||||
const SETTINGS_HREF = "/settings/hive-systems";
|
||||
|
||||
/** Candidates for „Wie anderes Volk“: living colonies of the same apiary first, then all others. */
|
||||
function otherColonies(colonies: Colony[] | undefined, colony: Colony): Colony[] {
|
||||
const living = (colonies ?? []).filter((candidate) => isLivingColony(candidate) && candidate.id !== colony.id);
|
||||
return [
|
||||
...living.filter((candidate) => candidate.apiaryId === colony.apiaryId),
|
||||
...living.filter((candidate) => candidate.apiaryId !== colony.apiaryId),
|
||||
];
|
||||
}
|
||||
|
||||
/** Read-only picture of a state that is not saved yet. */
|
||||
function Preview({ state, catalog }: { state: HiveState; catalog: HiveCatalog }) {
|
||||
const [store] = useState(createHiveViewStore);
|
||||
const layout = useMemo(() => hiveLayout(state, catalog), [state, catalog]);
|
||||
return (
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveSvg layout={layout} partName={(id) => partDisplayName(state, catalog, id)} />
|
||||
</HiveViewProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface Proposal {
|
||||
systemId: string;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
}
|
||||
|
||||
export interface HiveInitProps {
|
||||
colonyId: string;
|
||||
colony: Colony;
|
||||
/** colonies:update on a living colony; readers only see the text. */
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
/** Colony without a hive (spec 11): preview of the template or of another colony, then „Übernehmen“. */
|
||||
export function HiveInit({ colonyId, colony, canEdit }: HiveInitProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [otherId, setOtherId] = useState("");
|
||||
|
||||
const systems = useHiveSystems();
|
||||
const system = systems.data?.find((candidate) => candidate.active) ?? systems.data?.[0];
|
||||
const templateCatalog = useSystemCatalog(systems.data, system?.id);
|
||||
|
||||
const template = useQuery({
|
||||
queryKey: ["hive-systems", system?.id, "template"],
|
||||
queryFn: () => fetchHiveTemplate(system?.id ?? ""),
|
||||
enabled: canEdit && !copying && system !== undefined,
|
||||
});
|
||||
const colonies = useQuery({ queryKey: ["colonies"], queryFn: () => fetchColonies(), enabled: canEdit && copying });
|
||||
const other = useQuery({
|
||||
queryKey: hiveKeys.detail(otherId),
|
||||
queryFn: () => fetchHive(otherId),
|
||||
enabled: canEdit && copying && otherId !== "",
|
||||
});
|
||||
const otherCatalog = useSystemCatalog(systems.data, other.data?.systemId);
|
||||
|
||||
const proposal = useMemo<Proposal | null>(() => {
|
||||
if (!copying) {
|
||||
return system && templateCatalog && template.data
|
||||
? { systemId: system.id, state: template.data, catalog: templateCatalog }
|
||||
: null;
|
||||
}
|
||||
if (!other.data || !otherCatalog) return null;
|
||||
return {
|
||||
systemId: other.data.systemId,
|
||||
state: copyHiveFrom(other.data.state, otherCatalog),
|
||||
catalog: otherCatalog,
|
||||
};
|
||||
}, [copying, system, templateCatalog, template.data, other.data, otherCatalog]);
|
||||
|
||||
// The same proposed content keeps its key, so a repeat press after a network error cannot create twice.
|
||||
const keyRef = useRef<{ signature: string; key: string } | null>(null);
|
||||
function keyFor(current: Proposal): string {
|
||||
const signature = JSON.stringify([current.systemId, current.state]);
|
||||
if (keyRef.current?.signature !== signature) keyRef.current = { signature, key: newIdempotencyKey() };
|
||||
return keyRef.current.key;
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (current: Proposal) =>
|
||||
createHive(
|
||||
colonyId,
|
||||
{
|
||||
systemId: current.systemId,
|
||||
state: current.state,
|
||||
occurredOn: berlinCalendarDay(new Date().toISOString()),
|
||||
},
|
||||
keyFor(current),
|
||||
),
|
||||
onSuccess: (view) => {
|
||||
queryClient.setQueryData(hiveKeys.detail(colonyId), view);
|
||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.history(colonyId) });
|
||||
},
|
||||
onError: (error) => {
|
||||
// Someone created it meanwhile: show theirs.
|
||||
if (hiveErrorCode(error) === "HIVE_EXISTS") {
|
||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.detail(colonyId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Noch keine Beute angelegt</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const templateMissing = !copying && hiveErrorCode(template.error) === "TEMPLATE_NOT_SET";
|
||||
const otherHasNoHive = copying && hiveErrorCode(other.error) === "HIVE_NOT_FOUND";
|
||||
const loadFailed =
|
||||
systems.isError ||
|
||||
(!copying && template.isError && !templateMissing) ||
|
||||
(copying && other.isError && !otherHasNoHive);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Noch keine Beute angelegt</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{copying ? "Aufbau eines anderen Volks, ohne Namen und Füllstände." : "Startaufbau aus der Vorlage des Beutensystems."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative min-h-40 flex-1 overflow-hidden rounded-lg bg-holo-bg">
|
||||
<div className="absolute inset-0">
|
||||
{proposal ? <Preview state={proposal.state} catalog={proposal.catalog} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{templateMissing ? (
|
||||
<p role="status" className="text-sm">
|
||||
{NO_TEMPLATE_TEXT}{" "}
|
||||
{hasPermission("hive-systems:manage") ? (
|
||||
<Link href={SETTINGS_HREF} className="underline underline-offset-2">
|
||||
Zu den Einstellungen
|
||||
</Link>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
{otherHasNoHive ? <p className="text-sm">{OTHER_WITHOUT_HIVE_TEXT}</p> : null}
|
||||
{loadFailed ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{LOAD_FAILED_TEXT}
|
||||
</p>
|
||||
) : null}
|
||||
{copying ? (
|
||||
<Field id="hive-init-other" label="Volk" required>
|
||||
{({ id, describedBy, invalid }) => (
|
||||
<RecordSelect
|
||||
id={id}
|
||||
describedBy={describedBy}
|
||||
invalid={invalid}
|
||||
value={otherId}
|
||||
onChange={setOtherId}
|
||||
items={colonies.data ? otherColonies(colonies.data, colony) : undefined}
|
||||
getLabel={colonyOptionLabel}
|
||||
placeholder={colonies.isLoading ? "Völker werden geladen…" : "Volk wählen"}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
) : null}
|
||||
{create.isError ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{CREATE_FAILED_TEXT}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
disabled={proposal === null || create.isPending}
|
||||
onClick={() => proposal && create.mutate(proposal)}
|
||||
>
|
||||
Übernehmen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
onClick={() => {
|
||||
setCopying(!copying);
|
||||
setOtherId("");
|
||||
create.reset();
|
||||
}}
|
||||
>
|
||||
{copying ? "Aus Vorlage" : "Wie anderes Volk"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRef } from "react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import { HiveOutline } from "./hive-outline";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
|
||||
part("b1", "box", {
|
||||
frames: [
|
||||
{ slot: 1, type: "BROOD", name: "Königin" },
|
||||
{ slot: 2, type: "HONEY" },
|
||||
],
|
||||
}),
|
||||
part("b2", "box", { frames: [{ slot: 1, type: "FOUNDATION" }] }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 2.5, fondantKg: 0 } }),
|
||||
part("l", "inner-lid", { state: { feedHole: "CLOSED" } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
|
||||
function setup(props: Partial<React.ComponentProps<typeof HiveOutline>> = {}) {
|
||||
const store = createHiveViewStore();
|
||||
const onEditPart = vi.fn();
|
||||
const onEditFrame = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const view = render(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
canEdit
|
||||
announcement=""
|
||||
onEditPart={onEditPart}
|
||||
onEditFrame={onEditFrame}
|
||||
{...props}
|
||||
/>
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
return { store, onEditPart, onEditFrame, user, ...view };
|
||||
}
|
||||
|
||||
const names = () => screen.getAllByRole("treeitem").map((item) => item.textContent);
|
||||
|
||||
describe("HiveOutline", () => {
|
||||
it("is a labelled tree listing the roof first and the floor last", () => {
|
||||
setup();
|
||||
expect(screen.getByRole("tree", { name: "Aufbau der Beute" })).toBeInTheDocument();
|
||||
const items = names();
|
||||
expect(items[0]).toContain("Dach");
|
||||
expect(items[1]).toContain("Holzdeckel");
|
||||
expect(items[items.length - 1]).toContain("Boden");
|
||||
expect(items.findIndex((t) => t?.includes("Zarge 2"))).toBeLessThan(items.findIndex((t) => t?.includes("Zarge 1")));
|
||||
});
|
||||
|
||||
it("shows a short state hint per part", () => {
|
||||
setup();
|
||||
const text = names().join("|");
|
||||
expect(text).toContain("Futterzarge · 2,5 l / 0 kg");
|
||||
expect(text).toContain("Boden · Bausperre drin");
|
||||
expect(text).toContain("Holzdeckel · zu");
|
||||
});
|
||||
|
||||
it("marks boxes as expandable and keeps frames hidden until expanded", () => {
|
||||
setup();
|
||||
const box = screen.getByRole("treeitem", { name: /Zarge 1/ });
|
||||
expect(box).toHaveAttribute("aria-expanded", "false");
|
||||
expect(box).toHaveAttribute("aria-level", "1");
|
||||
expect(screen.queryByRole("treeitem", { name: /Brutwabe/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("has exactly one tabbable item (roving tabindex), the roof first", () => {
|
||||
setup();
|
||||
const tabbable = screen.getAllByRole("treeitem").filter((item) => item.getAttribute("tabindex") === "0");
|
||||
expect(tabbable).toHaveLength(1);
|
||||
expect(tabbable[0]).toHaveTextContent("Dach");
|
||||
});
|
||||
|
||||
it("moves with Down and Up and calls setHovered on focus", async () => {
|
||||
const { user, store } = setup();
|
||||
await user.tab();
|
||||
expect(screen.getByRole("treeitem", { name: /Dach/ })).toHaveFocus();
|
||||
expect(store.getState().hoveredId).toBe("r");
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(screen.getByRole("treeitem", { name: /Holzdeckel/ })).toHaveFocus();
|
||||
expect(store.getState().hoveredId).toBe("l");
|
||||
await user.keyboard("{ArrowUp}");
|
||||
expect(screen.getByRole("treeitem", { name: /Dach/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("jumps with Home and End", async () => {
|
||||
const { user } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{End}");
|
||||
expect(screen.getByRole("treeitem", { name: /Boden/ })).toHaveFocus();
|
||||
await user.keyboard("{Home}");
|
||||
expect(screen.getByRole("treeitem", { name: /Dach/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("expands a box with Right, enters it with Right again and collapses with Left", async () => {
|
||||
const { user, store } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}");
|
||||
const box = screen.getByRole("treeitem", { name: /Zarge 2/ });
|
||||
expect(box).toHaveFocus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(box).toHaveAttribute("aria-expanded", "true");
|
||||
expect(box).toHaveFocus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
const frame = screen.getByRole("treeitem", { name: /R1 · Mittelwand/ });
|
||||
expect(frame).toHaveFocus();
|
||||
expect(frame).toHaveAttribute("aria-level", "2");
|
||||
expect(store.getState().hoveredFrame).toEqual({ boxId: "b2", slot: 1 });
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(box).toHaveFocus();
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(box).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("lists frames as R<slot> · type · name", async () => {
|
||||
const { user } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}");
|
||||
expect(screen.getByRole("treeitem", { name: "R1 · Brutwabe · Königin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("treeitem", { name: "R2 · Honigwabe" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the box on Enter, expands it and edits a frame on Enter", async () => {
|
||||
const { user, store, onEditFrame } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}");
|
||||
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveFocus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(store.getState().openBoxId).toBe("b1");
|
||||
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveAttribute("aria-expanded", "true");
|
||||
await user.keyboard("{ArrowRight}{ArrowDown}");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onEditFrame).toHaveBeenCalledWith("b1", 2);
|
||||
});
|
||||
|
||||
it("edits a part with state fields on Enter and ignores parts without", async () => {
|
||||
const { user, onEditPart } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onEditPart).not.toHaveBeenCalled();
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
expect(onEditPart).toHaveBeenCalledWith("l");
|
||||
});
|
||||
|
||||
it("does not edit without canEdit but still opens boxes", async () => {
|
||||
const { user, store, onEditPart } = setup({ canEdit: false });
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
expect(onEditPart).not.toHaveBeenCalled();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{Enter}");
|
||||
expect(store.getState().openBoxId).toBe("b2");
|
||||
});
|
||||
|
||||
it("expands the box when it is opened from elsewhere", () => {
|
||||
const { store } = setup();
|
||||
act(() => store.openBox("b1"));
|
||||
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("treeitem", { name: /R1 · Brutwabe/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the announcement in a polite status region that is always present", () => {
|
||||
const { rerender, store } = setup();
|
||||
const region = screen.getByRole("status");
|
||||
expect(region).toHaveAttribute("aria-live", "polite");
|
||||
expect(region).not.toHaveClass("empty:hidden");
|
||||
rerender(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline
|
||||
state={state}
|
||||
catalog={catalog}
|
||||
canEdit
|
||||
announcement="Rähmchen 1 in Zarge 1 ist jetzt Brutwabe."
|
||||
onEditPart={() => {}}
|
||||
onEditFrame={() => {}}
|
||||
/>
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Rähmchen 1 in Zarge 1 ist jetzt Brutwabe.");
|
||||
});
|
||||
|
||||
it("gives every item a touch target of at least 44 px", () => {
|
||||
setup();
|
||||
for (const item of screen.getAllByRole("treeitem")) expect(item).toHaveClass("min-h-11");
|
||||
});
|
||||
|
||||
it("clears hovering when focus leaves the tree", async () => {
|
||||
const { user, store } = setup();
|
||||
await user.tab();
|
||||
expect(store.getState().hoveredId).toBe("r");
|
||||
await user.tab();
|
||||
expect(store.getState().hoveredId).toBeNull();
|
||||
});
|
||||
|
||||
it("edits the floor on Enter and keeps its hint", async () => {
|
||||
const { user, onEditPart } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{End}");
|
||||
expect(screen.getByRole("treeitem", { name: /Boden · Bausperre drin/ })).toHaveFocus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onEditPart).toHaveBeenCalledWith("f");
|
||||
});
|
||||
|
||||
it("offers Verdunster einsetzen as last child of a box without evaporator", async () => {
|
||||
const { user, onEditPart } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}");
|
||||
const action = screen.getByRole("treeitem", { name: /Verdunster einsetzen/ });
|
||||
expect(action).toHaveAttribute("aria-level", "2");
|
||||
expect(action.previousElementSibling).toHaveTextContent("R2 · Honigwabe");
|
||||
await user.click(action);
|
||||
expect(onEditPart).toHaveBeenCalledWith("b1");
|
||||
});
|
||||
|
||||
it("hides the add action without canEdit or when an evaporator exists", () => {
|
||||
const { unmount } = setup({ canEdit: false });
|
||||
act(() => {});
|
||||
expect(screen.queryByRole("treeitem", { name: /Verdunster einsetzen/ })).toBeNull();
|
||||
unmount();
|
||||
const withEvap: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [part("b1", "box", { frames: [], inserts: [part("e1", "evaporator")] })],
|
||||
};
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b1");
|
||||
render(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline state={withEvap} catalog={catalog} canEdit announcement="" onEditPart={() => {}} onEditFrame={() => {}} />
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
expect(screen.queryByRole("treeitem", { name: /Verdunster einsetzen/ })).toBeNull();
|
||||
expect(screen.getByRole("treeitem", { name: /Verdunster/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the open box with aria-current", () => {
|
||||
const { store } = setup();
|
||||
act(() => store.openBox("b1"));
|
||||
expect(screen.getByRole("treeitem", { name: /Zarge 1/ })).toHaveAttribute("aria-current", "true");
|
||||
});
|
||||
|
||||
it("moves focus to the nearest sibling when the focused item disappears", async () => {
|
||||
const { user, store, rerender } = setup();
|
||||
await user.tab();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
expect(screen.getByRole("treeitem", { name: /Holzdeckel/ })).toHaveFocus();
|
||||
const without: HiveState = { ...state, stack: state.stack.filter((p) => p.id !== "l") };
|
||||
rerender(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline state={without} catalog={catalog} canEdit announcement="" onEditPart={() => {}} onEditFrame={() => {}} />
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
expect(screen.getByRole("treeitem", { name: /Futterzarge/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
describe("imperative focus handle", () => {
|
||||
function withHandle(outlineState: HiveState = state) {
|
||||
const ref = createRef<HiveOutlineHandle>();
|
||||
const store = createHiveViewStore();
|
||||
render(
|
||||
<HiveViewProvider store={store}>
|
||||
<HiveOutline
|
||||
ref={ref}
|
||||
state={outlineState}
|
||||
catalog={catalog}
|
||||
canEdit
|
||||
announcement=""
|
||||
onEditPart={() => {}}
|
||||
onEditFrame={() => {}}
|
||||
/>
|
||||
</HiveViewProvider>,
|
||||
);
|
||||
return ref;
|
||||
}
|
||||
|
||||
it("focusPart focuses the item of a visible part", () => {
|
||||
const ref = withHandle();
|
||||
act(() => ref.current?.focusPart("fe"));
|
||||
expect(screen.getByRole("treeitem", { name: /Futterzarge/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("focusFrame expands the box and focuses the frame item", () => {
|
||||
const ref = withHandle();
|
||||
act(() => ref.current?.focusFrame("b1", 2));
|
||||
expect(screen.getByRole("treeitem", { name: /R2 · Honigwabe/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("focusPart on an insert inside a collapsed box expands the box first", () => {
|
||||
const withEvap: HiveState = {
|
||||
...state,
|
||||
stack: state.stack.map((p) =>
|
||||
p.id === "b1" ? { ...p, inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })] } : p,
|
||||
),
|
||||
};
|
||||
const ref = withHandle(withEvap);
|
||||
act(() => ref.current?.focusPart("ev"));
|
||||
expect(screen.getByRole("treeitem", { name: /Verdunster/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("focusPart on the hidden floor insert focuses its floor", () => {
|
||||
const ref = withHandle();
|
||||
act(() => ref.current?.focusPart("fi"));
|
||||
expect(screen.getByRole("treeitem", { name: /Boden/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("ignores an unknown id and leaves focus alone", () => {
|
||||
const ref = withHandle();
|
||||
act(() => ref.current?.focusPart("nope"));
|
||||
expect(document.body).toHaveFocus();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { FocusEvent, KeyboardEvent, Ref } from "react";
|
||||
import { FRAME_TYPE_LABELS } from "@/lib/hive/labels";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import type { CatalogPartType, HiveCatalog, HivePart, HiveState, StateField } from "@/lib/hive/types";
|
||||
import { useHiveView } from "./use-hive-view";
|
||||
|
||||
// Accessible outline of the hive (WAI-ARIA tree, spec 9.6). It offers everything
|
||||
// the 3D view does: hover, open a box, edit a frame or a part with state.
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
|
||||
|
||||
interface OutlineNode {
|
||||
key: string;
|
||||
kind: "part" | "frame" | "action";
|
||||
/** Part id, or the box id for a frame. */
|
||||
id: string;
|
||||
slot?: number;
|
||||
level: number;
|
||||
parentKey: string | null;
|
||||
childCount: number;
|
||||
name: string;
|
||||
hint: string | null;
|
||||
isBox: boolean;
|
||||
isFloor: boolean;
|
||||
hasState: boolean;
|
||||
setSize: number;
|
||||
position: number;
|
||||
}
|
||||
|
||||
function fieldText(field: StateField, value: number | string | undefined): string {
|
||||
if (field.kind === "choice") {
|
||||
const index = field.options.indexOf(String(value));
|
||||
return index >= 0 ? field.optionLabels[index] : String(value ?? "");
|
||||
}
|
||||
const text = numberFormat.format(Number(value ?? 0));
|
||||
return field.unit ? `${text} ${field.unit}` : text;
|
||||
}
|
||||
|
||||
function visibleInserts(part: HivePart, catalog: HiveCatalog): HivePart[] {
|
||||
return (part.inserts ?? []).filter((insert) => catalog.partTypes.get(insert.partTypeId)?.kind !== "FLOOR_INSERT");
|
||||
}
|
||||
|
||||
function hintOf(part: HivePart, type: CatalogPartType | undefined, catalog: HiveCatalog): string | null {
|
||||
if (!type) return null;
|
||||
if (type.stateSchema.length > 0) {
|
||||
return type.stateSchema.map((field) => fieldText(field, part.state[field.key])).join(" / ");
|
||||
}
|
||||
if (type.kind === "BOX") {
|
||||
const count = part.frames?.length ?? 0;
|
||||
return count > 0 ? `${count} Rähmchen` : null;
|
||||
}
|
||||
const hasLock = (part.inserts ?? []).some(
|
||||
(insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT",
|
||||
);
|
||||
return hasLock ? "Bausperre drin" : null;
|
||||
}
|
||||
|
||||
const ADD_EVAPORATOR_LABEL = "Verdunster einsetzen …";
|
||||
|
||||
function buildNodes(
|
||||
state: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
expanded: ReadonlySet<string>,
|
||||
canEdit: boolean,
|
||||
): OutlineNode[] {
|
||||
const canAddEvaporator = [...catalog.partTypes.values()].some((t) => t.kind === "EVAPORATOR" && t.active);
|
||||
const nodes: OutlineNode[] = [];
|
||||
const topDown = [...state.stack].reverse();
|
||||
|
||||
const addPart = (part: HivePart, level: number, parentKey: string | null, position: number, setSize: number) => {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
const inserts = visibleInserts(part, catalog);
|
||||
const frames = type?.kind === "BOX" ? (part.frames ?? []) : [];
|
||||
const isBox = type?.kind === "BOX";
|
||||
const hasEvaporator = inserts.some((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === "EVAPORATOR");
|
||||
const hasAddAction = isBox && canEdit && canAddEvaporator && !hasEvaporator;
|
||||
const childCount = inserts.length + frames.length + (hasAddAction ? 1 : 0);
|
||||
nodes.push({
|
||||
key: part.id,
|
||||
kind: "part",
|
||||
id: part.id,
|
||||
level,
|
||||
parentKey,
|
||||
childCount,
|
||||
name: partDisplayName(state, catalog, part.id),
|
||||
hint: hintOf(part, type, catalog),
|
||||
isBox,
|
||||
isFloor: type?.kind === "FLOOR",
|
||||
hasState: (type?.stateSchema.length ?? 0) > 0,
|
||||
setSize,
|
||||
position,
|
||||
});
|
||||
if (!expanded.has(part.id)) return;
|
||||
inserts.forEach((insert, index) => addPart(insert, level + 1, part.id, index + 1, childCount));
|
||||
frames.forEach((frame, index) => {
|
||||
const label = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
|
||||
nodes.push({
|
||||
key: `${part.id}#${frame.slot}`,
|
||||
kind: "frame",
|
||||
id: part.id,
|
||||
slot: frame.slot,
|
||||
level: level + 1,
|
||||
parentKey: part.id,
|
||||
childCount: 0,
|
||||
name: label,
|
||||
hint: null,
|
||||
isBox: false,
|
||||
isFloor: false,
|
||||
hasState: false,
|
||||
setSize: childCount,
|
||||
position: inserts.length + index + 1,
|
||||
});
|
||||
});
|
||||
if (hasAddAction) {
|
||||
nodes.push({
|
||||
key: `${part.id}#add-evaporator`,
|
||||
kind: "action",
|
||||
id: part.id,
|
||||
level: level + 1,
|
||||
parentKey: part.id,
|
||||
childCount: 0,
|
||||
name: ADD_EVAPORATOR_LABEL,
|
||||
hint: null,
|
||||
isBox: false,
|
||||
isFloor: false,
|
||||
hasState: false,
|
||||
setSize: childCount,
|
||||
position: childCount,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
topDown.forEach((part, index) => addPart(part, 1, null, index + 1, topDown.length));
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** Moves focus to an item, e.g. back to the edited one when a sheet closes. */
|
||||
export interface HiveOutlineHandle {
|
||||
focusPart(partId: string): void;
|
||||
focusFrame(boxId: string, slot: number): void;
|
||||
}
|
||||
|
||||
/** Outline key and the part that must be expanded to show it; null when the part is unknown. */
|
||||
function focusTargetOf(
|
||||
state: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
partId: string,
|
||||
): { key: string; expand: string | null } | null {
|
||||
for (const part of state.stack) {
|
||||
if (part.id === partId) return { key: partId, expand: null };
|
||||
const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId);
|
||||
if (!insert) continue;
|
||||
// Floor inserts have no item of their own; their floor stands for them.
|
||||
const hidden = catalog.partTypes.get(insert.partTypeId)?.kind === "FLOOR_INSERT";
|
||||
return hidden ? { key: part.id, expand: null } : { key: partId, expand: part.id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface HiveOutlineProps {
|
||||
ref?: Ref<HiveOutlineHandle>;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
canEdit: boolean;
|
||||
/** Text for the polite live region ("Rähmchen 4 in Zarge 1 ist jetzt Brutwabe."). */
|
||||
announcement: string;
|
||||
onEditPart: (id: string) => void;
|
||||
onEditFrame: (boxId: string, slot: number) => void;
|
||||
}
|
||||
|
||||
export function HiveOutline({ ref, state, catalog, canEdit, announcement, onEditPart, onEditFrame }: HiveOutlineProps) {
|
||||
const view = useHiveView();
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [seenOpenBox, setSeenOpenBox] = useState<string | null>(null);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const itemRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
|
||||
// A box opened elsewhere (3D, SVG) shows its frames here as well.
|
||||
if (view.openBoxId !== seenOpenBox) {
|
||||
setSeenOpenBox(view.openBoxId);
|
||||
if (view.openBoxId && !expanded.has(view.openBoxId)) setExpanded(new Set(expanded).add(view.openBoxId));
|
||||
}
|
||||
|
||||
const nodes = buildNodes(state, catalog, expanded, canEdit);
|
||||
const keySignature = nodes.map((node) => node.key).join("|");
|
||||
const previousNodes = useRef<OutlineNode[]>(nodes);
|
||||
const focusWithin = useRef(false);
|
||||
|
||||
// When the focused item disappears, focus moves to the nearest remaining
|
||||
// sibling, else its parent, else the first item.
|
||||
useLayoutEffect(() => {
|
||||
const before = previousNodes.current;
|
||||
previousNodes.current = nodes;
|
||||
if (!focusWithin.current || !activeKey) return;
|
||||
if (nodes.some((node) => node.key === activeKey)) return;
|
||||
const gone = before.findIndex((node) => node.key === activeKey);
|
||||
if (gone < 0) return;
|
||||
const present = new Set(nodes.map((node) => node.key));
|
||||
const parentKey = before[gone].parentKey;
|
||||
const siblings = (list: OutlineNode[]) => list.filter((n) => n.parentKey === parentKey && present.has(n.key));
|
||||
const next = siblings(before.slice(gone + 1))[0] ?? siblings(before.slice(0, gone)).reverse()[0];
|
||||
const target = next?.key ?? (parentKey && present.has(parentKey) ? parentKey : nodes[0]?.key);
|
||||
if (target) itemRefs.current.get(target)?.focus();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keySignature]);
|
||||
|
||||
// Focus requested for an item that only exists after its parent expands.
|
||||
const pendingFocus = useRef<string | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const key = pendingFocus.current;
|
||||
pendingFocus.current = null;
|
||||
if (key) itemRefs.current.get(key)?.focus();
|
||||
});
|
||||
|
||||
/** Expands a collapsed parent and focuses the item once it is rendered; otherwise does nothing. */
|
||||
const requestFocusAfterExpand = (itemKey: string, parentKey: string | null): void => {
|
||||
if (!parentKey || expanded.has(parentKey)) return;
|
||||
pendingFocus.current = itemKey;
|
||||
setExpanded(new Set(expanded).add(parentKey));
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focusPart(partId) {
|
||||
const target = focusTargetOf(state, catalog, partId);
|
||||
if (!target) return;
|
||||
const element = itemRefs.current.get(target.key);
|
||||
if (element) {
|
||||
element.focus();
|
||||
return;
|
||||
}
|
||||
requestFocusAfterExpand(target.key, target.expand);
|
||||
},
|
||||
focusFrame(boxId, slot) {
|
||||
const key = `${boxId}#${slot}`;
|
||||
const element = itemRefs.current.get(key);
|
||||
if (element) {
|
||||
element.focus();
|
||||
return;
|
||||
}
|
||||
requestFocusAfterExpand(key, boxId);
|
||||
},
|
||||
}));
|
||||
|
||||
const tabbableKey = nodes.some((node) => node.key === activeKey) ? activeKey : (nodes[0]?.key ?? null);
|
||||
|
||||
const toggle = (key: string, open: boolean): void => {
|
||||
const next = new Set(expanded);
|
||||
if (open) next.add(key);
|
||||
else next.delete(key);
|
||||
setExpanded(next);
|
||||
};
|
||||
|
||||
const focusKey = (key: string | undefined): void => {
|
||||
if (key) itemRefs.current.get(key)?.focus();
|
||||
};
|
||||
|
||||
const handleFocus = (node: OutlineNode): void => {
|
||||
focusWithin.current = true;
|
||||
setActiveKey(node.key);
|
||||
if (node.kind === "frame") {
|
||||
view.setHovered(null);
|
||||
view.setHoveredFrame({ boxId: node.id, slot: node.slot ?? 0 });
|
||||
} else {
|
||||
view.setHovered(node.id);
|
||||
view.setHoveredFrame(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
// A removed item also loses focus; the microtask lets the layout effect see the flag first.
|
||||
const tree = event.currentTarget.parentElement;
|
||||
queueMicrotask(() => {
|
||||
if (!tree?.contains(document.activeElement)) focusWithin.current = false;
|
||||
});
|
||||
if (event.currentTarget.parentElement?.contains(event.relatedTarget as Node | null)) return;
|
||||
view.setHovered(null);
|
||||
view.setHoveredFrame(null);
|
||||
};
|
||||
|
||||
const activateNode = (node: OutlineNode): void => {
|
||||
if (node.kind === "action") {
|
||||
if (canEdit) onEditPart(node.id);
|
||||
return;
|
||||
}
|
||||
if (node.kind === "frame") {
|
||||
if (canEdit) onEditFrame(node.id, node.slot ?? 0);
|
||||
return;
|
||||
}
|
||||
if (node.isBox) {
|
||||
view.openBox(node.id);
|
||||
toggle(node.id, true);
|
||||
return;
|
||||
}
|
||||
if (node.hasState || node.isFloor) {
|
||||
if (canEdit) onEditPart(node.id);
|
||||
return;
|
||||
}
|
||||
if (node.childCount > 0) toggle(node.key, !expanded.has(node.key));
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>, node: OutlineNode, index: number): void => {
|
||||
const isOpen = expanded.has(node.key);
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
focusKey(nodes[index + 1]?.key);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
focusKey(nodes[index - 1]?.key);
|
||||
break;
|
||||
case "Home":
|
||||
focusKey(nodes[0]?.key);
|
||||
break;
|
||||
case "End":
|
||||
focusKey(nodes[nodes.length - 1]?.key);
|
||||
break;
|
||||
case "ArrowRight":
|
||||
if (node.childCount === 0) break;
|
||||
if (!isOpen) toggle(node.key, true);
|
||||
else focusKey(nodes[index + 1]?.key);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
if (node.childCount > 0 && isOpen) toggle(node.key, false);
|
||||
else if (node.parentKey) focusKey(node.parentKey);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
activateNode(node);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div role="tree" aria-label="Aufbau der Beute" className="flex flex-col gap-1">
|
||||
{nodes.map((node, index) => {
|
||||
const isOpen = expanded.has(node.key);
|
||||
const isFrame = node.kind === "frame";
|
||||
const isCurrentBox = node.isBox && view.openBoxId === node.id;
|
||||
return (
|
||||
<div
|
||||
key={node.key}
|
||||
ref={(element) => {
|
||||
if (element) itemRefs.current.set(node.key, element);
|
||||
else itemRefs.current.delete(node.key);
|
||||
}}
|
||||
// aria-current marks the open box; aria-selected would suggest a selection model.
|
||||
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
|
||||
role="treeitem"
|
||||
aria-level={node.level}
|
||||
aria-setsize={node.setSize}
|
||||
aria-posinset={node.position}
|
||||
aria-expanded={node.childCount > 0 ? isOpen : undefined}
|
||||
aria-current={isCurrentBox ? "true" : undefined}
|
||||
tabIndex={node.key === tabbableKey ? 0 : -1}
|
||||
onFocus={() => handleFocus(node)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(event) => handleKeyDown(event, node, index)}
|
||||
onClick={() => {
|
||||
focusKey(node.key);
|
||||
activateNode(node);
|
||||
}}
|
||||
style={{ paddingInlineStart: `${0.75 + (node.level - 1) * 1.25}rem` }}
|
||||
className="flex min-h-11 cursor-pointer items-center gap-1 rounded-lg pe-3 text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=true]:bg-muted aria-[current=true]:font-medium"
|
||||
>
|
||||
<span className={isFrame ? "text-muted-foreground" : undefined}>
|
||||
{node.hint ? `${node.name} · ${node.hint}` : node.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{announcement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
export interface HiveSheetShellProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Where focus goes once the sheet is closed (Escape, "Schließen" or after saving). */
|
||||
finalFocus?: ComponentProps<typeof SheetContent>["finalFocus"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** The bottom sheet frame the hive sheets share: title, German close button, scroll area. */
|
||||
export function HiveSheetShell({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
finalFocus,
|
||||
children,
|
||||
}: HiveSheetShellProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
// The built-in close button is English; ours is German and 44 px high.
|
||||
showCloseButton={false}
|
||||
finalFocus={finalFocus}
|
||||
className="max-h-[90dvh] gap-0 overflow-y-auto rounded-t-2xl pb-(--safe-area-bottom) sm:mx-auto sm:max-w-lg"
|
||||
>
|
||||
<SheetHeader className="flex-row items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</div>
|
||||
<SheetClose render={<Button variant="ghost" size="touch" />}>Schließen</SheetClose>
|
||||
</SheetHeader>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { hiveLayout } from "@/lib/hive/layout";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveState } from "@/lib/hive/types";
|
||||
import { HiveSvg, projectPoint } from "./hive-svg";
|
||||
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b1", "box", {
|
||||
frames: [
|
||||
{ slot: 1, type: "BROOD", name: "Königin" },
|
||||
{ slot: 2, type: "HONEY" },
|
||||
{ slot: 3, type: "NONE" },
|
||||
],
|
||||
inserts: [part("e1", "evaporator")],
|
||||
}),
|
||||
part("b2", "box", { frames: frames(10) }),
|
||||
part("l", "inner-lid"),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
const layout = hiveLayout(state, catalog);
|
||||
|
||||
function renderSvg(ui: ReactNode, store = createHiveViewStore()) {
|
||||
return { store, ...render(<HiveViewProvider store={store}>{ui}</HiveViewProvider>) };
|
||||
}
|
||||
|
||||
describe("projectPoint", () => {
|
||||
it("applies the oblique projection with y down", () => {
|
||||
expect(projectPoint([2, 3, 4])).toEqual([4, -(3 - 0.35 * 4)]);
|
||||
expect(projectPoint([0, 0, 0])[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiveSvg", () => {
|
||||
it("is an img with a summary label and never crops", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
const svg = container.querySelector("svg")!;
|
||||
expect(svg).toHaveAttribute("role", "img");
|
||||
expect(svg).toHaveAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
expect(svg.getAttribute("aria-label")).toBe("Beute mit 2 Zargen, Boden, Holzdeckel, Dach");
|
||||
const [x, y, w, h] = svg.getAttribute("viewBox")!.split(" ").map(Number);
|
||||
expect(w).toBeGreaterThan(0);
|
||||
expect(h).toBeGreaterThan(0);
|
||||
expect(Number.isFinite(x + y)).toBe(true);
|
||||
});
|
||||
|
||||
it("fits the viewBox with 5 % padding around the projected bounds", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
const [x, , w] = container.querySelector("svg")!.getAttribute("viewBox")!.split(" ").map(Number);
|
||||
const left = Math.min(...layout.parts.map((p) => p.center[0] - p.size[0] / 2 + 0.5 * (-p.size[2] / 2)));
|
||||
const right = Math.max(...layout.parts.map((p) => p.center[0] + p.size[0] / 2 + 0.5 * (p.size[2] / 2)));
|
||||
const width = right - left;
|
||||
expect(x).toBeCloseTo(left - 0.05 * width, 5);
|
||||
expect(w).toBeCloseTo(width * 1.1, 5);
|
||||
});
|
||||
|
||||
it("draws one group per part with data-part-id and no frames while no box is open", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
const ids = [...container.querySelectorAll("g[data-part-id]")].map((g) => g.getAttribute("data-part-id"));
|
||||
expect(ids).toEqual(["f", "b1", "b2", "l", "r", "e1"]);
|
||||
expect(container.querySelectorAll("[data-frame-slot]")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is not in the tab order and has no button roles without handlers", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
expect(container.querySelectorAll("[role=button]")).toHaveLength(0);
|
||||
expect(container.querySelectorAll('[tabindex="0"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("makes parts buttons with tabIndex -1 when onSelectPart is given", () => {
|
||||
const onSelectPart = vi.fn();
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} onSelectPart={onSelectPart} />);
|
||||
const group = container.querySelector('g[data-part-id="b2"]')!;
|
||||
expect(group).toHaveAttribute("role", "button");
|
||||
expect(group).toHaveAttribute("tabindex", "-1");
|
||||
fireEvent.click(group);
|
||||
expect(onSelectPart).toHaveBeenCalledWith("b2");
|
||||
});
|
||||
|
||||
it("draws frames only for the open box, each with its type word as title", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b1");
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />, store);
|
||||
const slots = [...container.querySelectorAll("[data-frame-slot]")];
|
||||
expect(slots).toHaveLength(3);
|
||||
const titles = slots.map((el) => el.querySelector("title")!.textContent);
|
||||
expect(titles[0]).toContain("Brutwabe");
|
||||
expect(titles[0]).toContain("Königin");
|
||||
expect(titles[1]).toContain("Honigwabe");
|
||||
expect(titles[2]).toContain("leer");
|
||||
expect(container.querySelectorAll('[data-frame-box="b2"]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("namespaces pattern ids and references them from the frames", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b1");
|
||||
const { container } = renderSvg(
|
||||
<>
|
||||
<HiveSvg layout={layout} />
|
||||
<HiveSvg layout={layout} />
|
||||
</>,
|
||||
store,
|
||||
);
|
||||
const ids = [...container.querySelectorAll("pattern")].map((p) => p.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
const fill = container.querySelector("[data-frame-slot='1'] rect")!.getAttribute("fill")!;
|
||||
const target = /url\(#(.+)\)/.exec(fill)![1];
|
||||
expect(container.querySelector(`pattern[id="${target}"]`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("selects frames when onSelectFrame is given", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b1");
|
||||
const onSelectFrame = vi.fn();
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} onSelectFrame={onSelectFrame} />, store);
|
||||
const frame = container.querySelector("[data-frame-slot='2']")!;
|
||||
expect(frame).toHaveAttribute("role", "button");
|
||||
expect(frame).toHaveAttribute("tabindex", "-1");
|
||||
fireEvent.click(frame);
|
||||
expect(onSelectFrame).toHaveBeenCalledWith("b1", 2);
|
||||
});
|
||||
|
||||
it("uses currentColor based strokes and no hard-coded colours", () => {
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />);
|
||||
expect(container.innerHTML).not.toMatch(/#[0-9a-f]{3,6}\b|rgb\(/i);
|
||||
expect(container.querySelector("path")?.getAttribute("class")).toContain("stroke-current");
|
||||
});
|
||||
|
||||
it("marks the hovered part", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.setHovered("l");
|
||||
const { container } = renderSvg(<HiveSvg layout={layout} />, store);
|
||||
expect(container.querySelector('g[data-part-id="l"]')).toHaveAttribute("data-hovered", "true");
|
||||
expect(container.querySelector('g[data-part-id="r"]')).not.toHaveAttribute("data-hovered");
|
||||
});
|
||||
|
||||
it("renders an empty hive without crashing", () => {
|
||||
renderSvg(<HiveSvg layout={hiveLayout({ schemaVersion: 1, stack: [] }, catalog)} />);
|
||||
expect(screen.getByRole("img")).toHaveAttribute("aria-label", "Beute ohne Teile");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { FRAME_TYPE_LABELS, PART_KIND_LABELS } from "@/lib/hive/labels";
|
||||
import type { FrameBox, HiveLayout, PartBox, Vec3 } from "@/lib/hive/layout";
|
||||
import type { FrameType } from "@/lib/hive/types";
|
||||
import { useHiveView } from "./use-hive-view";
|
||||
|
||||
// SVG view of the hive: fallback without WebGL, print, and the L2 visual.
|
||||
// Oblique projection of the shared layout (1 unit = 100 mm, y up). Strokes use
|
||||
// currentColor so forced-colors and print follow the surrounding text colour.
|
||||
|
||||
const SKEW_X = 0.5;
|
||||
const SKEW_Y = 0.35;
|
||||
const PADDING = 0.05;
|
||||
const FRAME_FILL_RATIO = 0.9;
|
||||
const PATTERN_TILE = 0.16;
|
||||
const EMPTY_VIEW_BOX = "0 0 1 1";
|
||||
|
||||
type Point = [number, number];
|
||||
|
||||
export function projectPoint([x, y, z]: Vec3): Point {
|
||||
return [x + SKEW_X * z, -(y - SKEW_Y * z)];
|
||||
}
|
||||
|
||||
const fmt = (value: number): string => Number(value.toFixed(4)).toString();
|
||||
|
||||
function polygon(points: Vec3[]): string {
|
||||
return (
|
||||
points
|
||||
.map((point, index) => {
|
||||
const [x, y] = projectPoint(point);
|
||||
return `${index === 0 ? "M" : "L"}${fmt(x)} ${fmt(y)}`;
|
||||
})
|
||||
.join(" ") + " Z"
|
||||
);
|
||||
}
|
||||
|
||||
function corners(box: { center: Vec3; size: Vec3 }): Vec3[] {
|
||||
const [cx, cy, cz] = box.center;
|
||||
const [w, h, d] = box.size;
|
||||
const result: Vec3[] = [];
|
||||
for (const dx of [-1, 1]) for (const dy of [-1, 1]) for (const dz of [-1, 1]) {
|
||||
result.push([cx + (dx * w) / 2, cy + (dy * h) / 2, cz + (dz * d) / 2]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function viewBoxOf(layout: HiveLayout): string {
|
||||
const points = [...layout.parts, ...layout.inserts].flatMap((part) => corners(part).map(projectPoint));
|
||||
if (points.length === 0) return EMPTY_VIEW_BOX;
|
||||
const xs = points.map(([x]) => x);
|
||||
const ys = points.map(([, y]) => y);
|
||||
const minX = Math.min(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const width = Math.max(...xs) - minX;
|
||||
const height = Math.max(...ys) - minY;
|
||||
return [minX - PADDING * width, minY - PADDING * height, width * (1 + 2 * PADDING), height * (1 + 2 * PADDING)]
|
||||
.map(String)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** "Beute mit 2 Zargen, Boden, Dach": boxes first, then the rest bottom to top. */
|
||||
function summaryOf(layout: HiveLayout): string {
|
||||
if (layout.parts.length === 0) return "Beute ohne Teile";
|
||||
const counts = new Map<string, number>();
|
||||
for (const part of layout.parts) {
|
||||
const label = PART_KIND_LABELS[part.kind];
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
const boxLabel = PART_KIND_LABELS.BOX;
|
||||
const labels = [...counts.keys()].sort((a, b) => Number(b === boxLabel) - Number(a === boxLabel));
|
||||
const items = labels.map((label) => {
|
||||
const count = counts.get(label) ?? 1;
|
||||
if (count === 1) return label;
|
||||
return label === boxLabel ? `${count} Zargen` : `${count}× ${label}`;
|
||||
});
|
||||
return `Beute mit ${items.join(", ")}`;
|
||||
}
|
||||
|
||||
function framePitch(frame: FrameBox, all: FrameBox[]): number {
|
||||
const xs = all.filter((other) => other.boxId === frame.boxId).map((other) => other.center[0]).sort((a, b) => a - b);
|
||||
const gaps = xs.slice(1).map((x, index) => x - xs[index]);
|
||||
return gaps.length > 0 ? Math.min(...gaps) : frame.size[0];
|
||||
}
|
||||
|
||||
// ---- Frame patterns: one per type, so the type never rides on colour alone ----
|
||||
|
||||
const T = PATTERN_TILE;
|
||||
const PATTERNS: Record<Exclude<FrameType, "NONE">, ReactNode> = {
|
||||
BROOD: <circle cx={T / 2} cy={T / 2} r={0.035} className="fill-current stroke-none" />,
|
||||
DRONE: <circle cx={T / 2} cy={T / 2} r={0.06} className="fill-current stroke-none" />,
|
||||
HONEY: <path d={`M0 ${T} L${T} 0`} />,
|
||||
POLLEN: <path d={`M${T / 2} 0 V${T} M0 ${T / 2} H${T}`} />,
|
||||
FEED: <path d={`M0 ${T / 2} H${T}`} />,
|
||||
FOUNDATION: <path d={`M${T / 2} 0 V${T}`} />,
|
||||
EMPTY_COMB: <circle cx={T / 2} cy={T / 2} r={0.05} className="fill-none" />,
|
||||
DRONE_FRAME: <path d={`M0 0 L${T} ${T} M0 ${T} L${T} 0`} />,
|
||||
DIVIDER: <rect width={T} height={T} className="fill-current opacity-60 stroke-none" />,
|
||||
};
|
||||
|
||||
const PATTERN_TYPES = Object.keys(PATTERNS) as Exclude<FrameType, "NONE">[];
|
||||
|
||||
// Colour comes from the <svg>; only hover changes it, never in print.
|
||||
const HOVER_COLOR = "data-[hovered=true]:text-holo-edge-strong print:data-[hovered=true]:text-black";
|
||||
|
||||
const edge = "fill-none stroke-current [vector-effect:non-scaling-stroke]";
|
||||
|
||||
function activate(event: KeyboardEvent, action: () => void): void {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
|
||||
export interface HiveSvgProps {
|
||||
layout: HiveLayout;
|
||||
onSelectPart?: (id: string) => void;
|
||||
onSelectFrame?: (boxId: string, slot: number) => void;
|
||||
/** Display name per part (see partDisplayName); defaults to the kind label. */
|
||||
partName?: (id: string) => string;
|
||||
}
|
||||
|
||||
interface PartShapeProps {
|
||||
part: PartBox;
|
||||
dashed?: boolean;
|
||||
name: string;
|
||||
hovered: boolean;
|
||||
onSelect?: (id: string) => void;
|
||||
onHover: (id: string | null) => void;
|
||||
}
|
||||
|
||||
function PartShape({ part, dashed, name, hovered, onSelect, onHover }: PartShapeProps) {
|
||||
const [cx, cy, cz] = part.center;
|
||||
const [w, h, d] = part.size;
|
||||
const [x0, x1, y0, y1, z0, z1] = [cx - w / 2, cx + w / 2, cy - h / 2, cy + h / 2, cz - d / 2, cz + d / 2];
|
||||
const faces = [
|
||||
polygon([[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0]]),
|
||||
polygon([[x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1]]),
|
||||
polygon([[x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0]]),
|
||||
];
|
||||
const interactive = onSelect
|
||||
? {
|
||||
role: "button" as const,
|
||||
tabIndex: -1,
|
||||
onClick: () => onSelect(part.id),
|
||||
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(part.id)),
|
||||
onPointerEnter: () => onHover(part.id),
|
||||
onPointerLeave: () => onHover(null),
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<g
|
||||
data-part-id={part.id}
|
||||
data-hovered={hovered ? "true" : undefined}
|
||||
className={`${HOVER_COLOR} ${onSelect ? "cursor-pointer" : ""}`}
|
||||
{...interactive}
|
||||
>
|
||||
<title>{name}</title>
|
||||
{faces.map((d_, index) => (
|
||||
<path
|
||||
key={index}
|
||||
d={d_}
|
||||
className={`${edge} fill-holo-fill ${dashed ? "[stroke-dasharray:4_3]" : ""}`}
|
||||
strokeWidth={hovered ? 2.5 : 1.5}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
interface FrameShapeProps {
|
||||
frame: FrameBox;
|
||||
width: number;
|
||||
patternPrefix: string;
|
||||
hovered: boolean;
|
||||
onSelect?: (boxId: string, slot: number) => void;
|
||||
onHover: (frame: { boxId: string; slot: number } | null) => void;
|
||||
}
|
||||
|
||||
function FrameShape({ frame, width, patternPrefix, hovered, onSelect, onHover }: FrameShapeProps) {
|
||||
const [cx, cy, cz] = frame.center;
|
||||
const [, h, d] = frame.size;
|
||||
const [x, y] = projectPoint([cx - width / 2, cy + h / 2, cz - d / 2]);
|
||||
const [x2, y2] = projectPoint([cx + width / 2, cy - h / 2, cz - d / 2]);
|
||||
const title = [`R${frame.slot}`, FRAME_TYPE_LABELS[frame.type], frame.name].filter(Boolean).join(" · ");
|
||||
const ref = { boxId: frame.boxId, slot: frame.slot };
|
||||
const interactive = onSelect
|
||||
? {
|
||||
role: "button" as const,
|
||||
tabIndex: -1,
|
||||
onClick: () => onSelect(frame.boxId, frame.slot),
|
||||
onKeyDown: (event: KeyboardEvent) => activate(event, () => onSelect(frame.boxId, frame.slot)),
|
||||
onPointerEnter: () => onHover(ref),
|
||||
onPointerLeave: () => onHover(null),
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<g
|
||||
data-frame-box={frame.boxId}
|
||||
data-frame-slot={frame.slot}
|
||||
data-frame-type={frame.type}
|
||||
data-hovered={hovered ? "true" : undefined}
|
||||
className={`${HOVER_COLOR} ${onSelect ? "cursor-pointer" : ""}`}
|
||||
{...interactive}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={x2 - x}
|
||||
height={y2 - y}
|
||||
fill={frame.type === "NONE" ? "none" : `url(#${patternPrefix}-${frame.type})`}
|
||||
className={`${edge} ${frame.type === "NONE" ? "[stroke-dasharray:2_3]" : ""}`}
|
||||
strokeWidth={hovered ? 2.5 : 1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function HiveSvg({ layout, onSelectPart, onSelectFrame, partName }: HiveSvgProps) {
|
||||
const view = useHiveView();
|
||||
const patternPrefix = `hive${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
|
||||
const nameOf = (part: PartBox): string => partName?.(part.id) ?? PART_KIND_LABELS[part.kind];
|
||||
const frames = view.openBoxId ? layout.frames.filter((frame) => frame.boxId === view.openBoxId) : [];
|
||||
const hoveredFrame = view.hoveredFrame;
|
||||
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
aria-label={summaryOf(layout)}
|
||||
viewBox={viewBoxOf(layout)}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
className="block h-full w-full bg-holo-bg text-holo-edge forced-colors:bg-[Canvas] forced-colors:text-[CanvasText] print:bg-transparent print:text-black"
|
||||
>
|
||||
{frames.length > 0 ? (
|
||||
<defs>
|
||||
{PATTERN_TYPES.map((type) => (
|
||||
<pattern
|
||||
key={type}
|
||||
id={`${patternPrefix}-${type}`}
|
||||
width={PATTERN_TILE}
|
||||
height={PATTERN_TILE}
|
||||
patternUnits="userSpaceOnUse"
|
||||
className="fill-none stroke-current"
|
||||
strokeWidth={0.02}
|
||||
>
|
||||
{PATTERNS[type]}
|
||||
</pattern>
|
||||
))}
|
||||
</defs>
|
||||
) : null}
|
||||
{layout.parts.map((part) => (
|
||||
<PartShape
|
||||
key={part.id}
|
||||
part={part}
|
||||
name={nameOf(part)}
|
||||
hovered={view.hoveredId === part.id}
|
||||
onSelect={onSelectPart}
|
||||
onHover={view.setHovered}
|
||||
/>
|
||||
))}
|
||||
{layout.inserts.map((insert) => (
|
||||
<PartShape
|
||||
key={insert.id}
|
||||
part={insert}
|
||||
dashed
|
||||
name={nameOf(insert)}
|
||||
hovered={view.hoveredId === insert.id}
|
||||
onSelect={onSelectPart}
|
||||
onHover={view.setHovered}
|
||||
/>
|
||||
))}
|
||||
{frames.map((frame) => (
|
||||
<FrameShape
|
||||
key={`${frame.boxId}-${frame.slot}`}
|
||||
frame={frame}
|
||||
width={framePitch(frame, frames) * FRAME_FILL_RATIO}
|
||||
patternPrefix={patternPrefix}
|
||||
hovered={hoveredFrame?.boxId === frame.boxId && hoveredFrame.slot === frame.slot}
|
||||
onSelect={onSelectFrame}
|
||||
onHover={view.setHoveredFrame}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import type { HiveLayout } from "@/lib/hive/layout";
|
||||
import type { HiveCatalog } from "@/lib/hive/types";
|
||||
import { HiveSvg } from "./hive-svg";
|
||||
|
||||
export interface HiveVisualSlotProps {
|
||||
layout: HiveLayout;
|
||||
catalog: HiveCatalog;
|
||||
partName: (id: string) => string;
|
||||
onSelectPart: (id: string) => void;
|
||||
onSelectFrame: (boxId: string, slot: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of the hive. L2 shows the SVG; the 3D scene (with the SVG as
|
||||
* fallback) replaces this body without changing the props.
|
||||
*/
|
||||
export function HiveVisualSlot({ layout, partName, onSelectPart, onSelectFrame }: HiveVisualSlotProps) {
|
||||
return <HiveSvg layout={layout} partName={partName} onSelectPart={onSelectPart} onSelectFrame={onSelectFrame} />;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SheetFooter } from "@/components/ui/sheet";
|
||||
import { partDisplayName } from "@/lib/hive/names";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HivePartKind, HiveState } from "@/lib/hive/types";
|
||||
import type { HiveOutlineHandle } from "./hive-outline";
|
||||
import { HiveSheetShell } from "./hive-sheet-shell";
|
||||
import {
|
||||
areDraftFieldsValid,
|
||||
draftOf,
|
||||
draftValue,
|
||||
isFieldChanged,
|
||||
StateFieldControl,
|
||||
} from "./state-field-control";
|
||||
import type { FieldDraft } from "./state-field-control";
|
||||
import { useSheetSubmit } from "./use-sheet-submit";
|
||||
import type { SubmitOps } from "./use-sheet-submit";
|
||||
|
||||
const INSERT_OPTIONS = [
|
||||
{ value: true, label: "drin" },
|
||||
{ value: false, label: "draußen" },
|
||||
];
|
||||
const EVAPORATOR_OPTIONS = [
|
||||
{ value: false, label: "Kein Verdunster" },
|
||||
{ value: true, label: "Verdunster einsetzen" },
|
||||
];
|
||||
|
||||
export interface PartSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
state: HiveState;
|
||||
catalog: HiveCatalog;
|
||||
partId: string;
|
||||
/** Sends the ops (source CARD); rejects on failure. */
|
||||
submit: SubmitOps;
|
||||
/** On close, focus returns to the edited part's item in the outline. */
|
||||
outlineRef: RefObject<HiveOutlineHandle | null>;
|
||||
/** Opens a box with the evaporator already chosen (the outline's „Verdunster einsetzen“ action). */
|
||||
startWithEvaporator?: boolean;
|
||||
}
|
||||
|
||||
interface Located {
|
||||
part: HivePart;
|
||||
/** The box or floor an insert sits in. */
|
||||
host: HivePart | null;
|
||||
}
|
||||
|
||||
function locate(state: HiveState, partId: string): Located | null {
|
||||
for (const part of state.stack) {
|
||||
if (part.id === partId) return { part, host: null };
|
||||
const insert = (part.inserts ?? []).find((candidate) => candidate.id === partId);
|
||||
if (insert) return { part: insert, host: part };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function activeTypeOf(catalog: HiveCatalog, kind: HivePartKind): CatalogPartType | undefined {
|
||||
return [...catalog.partTypes.values()].find((type) => type.kind === kind && type.active);
|
||||
}
|
||||
|
||||
function insertOfKind(part: HivePart, catalog: HiveCatalog, kind: HivePartKind): HivePart | undefined {
|
||||
return (part.inserts ?? []).find((insert) => catalog.partTypes.get(insert.partTypeId)?.kind === kind);
|
||||
}
|
||||
|
||||
function newPart(id: string, type: CatalogPartType, draft: FieldDraft): HivePart {
|
||||
const state: Record<string, number | string> = {};
|
||||
for (const field of type.stateSchema) state[field.key] = draftValue(field, draft[field.key] ?? "");
|
||||
return { id, partTypeId: type.id, state };
|
||||
}
|
||||
|
||||
interface PartFormProps {
|
||||
located: Located;
|
||||
type: CatalogPartType;
|
||||
catalog: HiveCatalog;
|
||||
startWithEvaporator: boolean;
|
||||
submit: SubmitOps;
|
||||
/** `focusId` is the part whose item should get focus once the sheet is closed. */
|
||||
onDone: (focusId: string) => void;
|
||||
}
|
||||
|
||||
function PartForm({ located, type, catalog, startWithEvaporator, submit, onDone }: PartFormProps) {
|
||||
const { part, host } = located;
|
||||
const floorInsertType = type.kind === "FLOOR" ? activeTypeOf(catalog, "FLOOR_INSERT") : undefined;
|
||||
const currentFloorInsert = type.kind === "FLOOR" ? insertOfKind(part, catalog, "FLOOR_INSERT") : undefined;
|
||||
const evaporatorType =
|
||||
type.kind === "BOX" && !insertOfKind(part, catalog, "EVAPORATOR") ? activeTypeOf(catalog, "EVAPORATOR") : undefined;
|
||||
|
||||
const [draft, setDraft] = useState<FieldDraft>(() => draftOf(type, part.state));
|
||||
const [wantsFloorInsert, setWantsFloorInsert] = useState(currentFloorInsert !== undefined);
|
||||
const [wantsEvaporator, setWantsEvaporator] = useState(startWithEvaporator && evaporatorType !== undefined);
|
||||
const [evaporatorDraft, setEvaporatorDraft] = useState<FieldDraft>(() =>
|
||||
evaporatorType ? draftOf(evaporatorType, {}) : {},
|
||||
);
|
||||
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
|
||||
const questionRef = useRef<HTMLParagraphElement>(null);
|
||||
const removeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const wasConfirming = useRef(false);
|
||||
|
||||
// Keyboard and screen reader users follow the confirmation, and get back to the button on cancel.
|
||||
useEffect(() => {
|
||||
if (confirmingRemoval) questionRef.current?.focus();
|
||||
else if (wasConfirming.current) removeButtonRef.current?.focus();
|
||||
wasConfirming.current = confirmingRemoval;
|
||||
}, [confirmingRemoval]);
|
||||
const { send, pending, error } = useSheetSubmit(submit);
|
||||
// Fixed per form, so a retry after an error sends the same ids.
|
||||
const [newIds] = useState(() => ({ floorInsert: newIdempotencyKey(), evaporator: newIdempotencyKey() }));
|
||||
|
||||
const changedStateOps: HiveOp[] = type.stateSchema.flatMap((field) =>
|
||||
isFieldChanged(field, draft[field.key] ?? "", part.state[field.key])
|
||||
? [{ op: "setState" as const, id: part.id, key: field.key, value: draftValue(field, draft[field.key]) }]
|
||||
: [],
|
||||
);
|
||||
const ops: HiveOp[] = [...changedStateOps];
|
||||
if (floorInsertType && wantsFloorInsert && !currentFloorInsert) {
|
||||
ops.push({ op: "addInsert", hostId: part.id, part: newPart(newIds.floorInsert, floorInsertType, {}) });
|
||||
} else if (currentFloorInsert && !wantsFloorInsert) {
|
||||
ops.push({ op: "removePart", id: currentFloorInsert.id });
|
||||
}
|
||||
const evaporatorToInsert = evaporatorType && wantsEvaporator ? newPart(newIds.evaporator, evaporatorType, evaporatorDraft) : null;
|
||||
if (evaporatorToInsert) ops.push({ op: "addInsert", hostId: part.id, part: evaporatorToInsert });
|
||||
|
||||
const valid =
|
||||
areDraftFieldsValid(type, draft) &&
|
||||
(!evaporatorType || !wantsEvaporator || areDraftFieldsValid(evaporatorType, evaporatorDraft));
|
||||
|
||||
async function save() {
|
||||
// After inserting an evaporator, focus goes to the new item.
|
||||
const outcome = await send(ops);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (evaporatorToInsert?.id ?? part.id) : part.id);
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
// An evaporator is an insert: focus goes to its box afterwards.
|
||||
const outcome = await send([{ op: "removePart", id: part.id }]);
|
||||
if (outcome !== "failed") onDone(outcome === "saved" ? (host?.id ?? part.id) : part.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 px-4 pb-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (ops.length > 0 && valid && !pending) void save();
|
||||
}}
|
||||
>
|
||||
{type.stateSchema.map((field) => (
|
||||
<StateFieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
idPrefix={`part-${part.id}`}
|
||||
value={draft[field.key] ?? ""}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, [field.key]: value }))}
|
||||
/>
|
||||
))}
|
||||
{floorInsertType || currentFloorInsert ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={`part-${part.id}-insert`} className="text-sm font-medium">
|
||||
Bausperre
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={INSERT_OPTIONS}
|
||||
value={wantsFloorInsert}
|
||||
onChange={setWantsFloorInsert}
|
||||
labelledBy={`part-${part.id}-insert`}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{evaporatorType ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span id={`part-${part.id}-evaporator`} className="text-sm font-medium">
|
||||
Verdunster
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={EVAPORATOR_OPTIONS}
|
||||
value={wantsEvaporator}
|
||||
onChange={setWantsEvaporator}
|
||||
labelledBy={`part-${part.id}-evaporator`}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
{wantsEvaporator
|
||||
? evaporatorType.stateSchema.map((field) => (
|
||||
<StateFieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
idPrefix={`part-${part.id}-new-evaporator`}
|
||||
value={evaporatorDraft[field.key] ?? ""}
|
||||
onChange={(value) => setEvaporatorDraft((current) => ({ ...current, [field.key]: value }))}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SheetFooter className="p-0">
|
||||
<Button type="submit" size="touch" disabled={ops.length === 0 || !valid || pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
{type.kind === "EVAPORATOR" ? (
|
||||
confirmingRemoval ? (
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={`part-${part.id}-confirm`}
|
||||
className="flex flex-col gap-2 rounded-lg border border-destructive/40 p-3"
|
||||
>
|
||||
<p id={`part-${part.id}-confirm`} ref={questionRef} tabIndex={-1} className="text-sm outline-none">
|
||||
Verdunster wirklich entfernen?
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" variant="destructive" size="touch" disabled={pending} onClick={() => void remove()}>
|
||||
Ja, entfernen
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="touch" onClick={() => setConfirmingRemoval(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
ref={removeButtonRef}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="touch"
|
||||
onClick={() => setConfirmingRemoval(true)}
|
||||
>
|
||||
Verdunster entfernen
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</SheetFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit one part: a control per state field, the floor's Bausperre, a box's
|
||||
* evaporator (spec 9.4). Everything changed goes out as one batch of ops.
|
||||
*/
|
||||
export function PartSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
state,
|
||||
catalog,
|
||||
partId,
|
||||
submit,
|
||||
outlineRef,
|
||||
startWithEvaporator = false,
|
||||
}: PartSheetProps) {
|
||||
const located = locate(state, partId);
|
||||
const type = located ? catalog.partTypes.get(located.part.partTypeId) : undefined;
|
||||
// Set by a save that changes what should get focus (a new evaporator, the box of a removed one).
|
||||
const focusAfterSave = useRef<string | null>(null);
|
||||
|
||||
return (
|
||||
<HiveSheetShell
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={partDisplayName(state, catalog, partId)}
|
||||
finalFocus={() => {
|
||||
outlineRef.current?.focusPart(focusAfterSave.current ?? partId);
|
||||
focusAfterSave.current = null;
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
{located && type ? (
|
||||
<PartForm
|
||||
key={partId}
|
||||
located={located}
|
||||
type={type}
|
||||
catalog={catalog}
|
||||
startWithEvaporator={startWithEvaporator}
|
||||
submit={submit}
|
||||
onDone={(id) => {
|
||||
focusAfterSave.current = id;
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p role="alert" className="px-4 pb-4 text-sm text-destructive">
|
||||
Dieses Teil gibt es nicht mehr.
|
||||
</p>
|
||||
)}
|
||||
</HiveSheetShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { ChoiceGroup } from "@/components/form/choice-group";
|
||||
import { NumberStepper } from "@/components/form/number-stepper";
|
||||
import { LEVEL_TOLERANCE } from "@/lib/hive/types";
|
||||
import type { CatalogPartType, ChoiceField, LevelField, StateField } from "@/lib/hive/types";
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
|
||||
|
||||
/** Digits after the decimal point of a step: 0.5 -> 1, 10 -> 0. */
|
||||
export function decimalsOf(step: number): number {
|
||||
const text = String(step);
|
||||
const dot = text.indexOf(".");
|
||||
return dot < 0 ? 0 : text.length - dot - 1;
|
||||
}
|
||||
|
||||
/** Draft text per state field key ("2.5" for levels, the option for choices). */
|
||||
export type FieldDraft = Record<string, string>;
|
||||
|
||||
export function draftOf(type: CatalogPartType, state: Record<string, number | string>): FieldDraft {
|
||||
const draft: FieldDraft = {};
|
||||
for (const field of type.stateSchema) {
|
||||
const value = state[field.key];
|
||||
draft[field.key] = value === undefined ? defaultText(field) : String(value);
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
function defaultText(field: StateField): string {
|
||||
return field.kind === "level" ? "0" : (field.options[0] ?? "");
|
||||
}
|
||||
|
||||
/** Empty, out of 0..max or off the step grid: the value must not be saved. */
|
||||
export function isLevelValid(raw: string, field: LevelField): boolean {
|
||||
return levelProblem(raw, field) === null && !isOffGrid(raw, field);
|
||||
}
|
||||
|
||||
function isOffGrid(raw: string, field: LevelField): boolean {
|
||||
const position = Number(raw) / field.step;
|
||||
return Math.abs(position - Math.round(position)) > LEVEL_TOLERANCE;
|
||||
}
|
||||
|
||||
/** Message for an empty or out-of-range level; the stepper words the grid error itself. */
|
||||
export function levelProblem(raw: string, field: LevelField): string | null {
|
||||
const value = raw === "" ? Number.NaN : Number(raw);
|
||||
if (Number.isNaN(value)) return "Bitte einen Wert angeben.";
|
||||
if (value < 0 || value > field.max) return `Nur Werte von 0 bis ${numberFormat.format(field.max)}.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function areDraftFieldsValid(type: CatalogPartType, draft: FieldDraft): boolean {
|
||||
return type.stateSchema.every((field) => field.kind === "choice" || isLevelValid(draft[field.key] ?? "", field));
|
||||
}
|
||||
|
||||
/** The value as the API takes it: a number for levels, the option for choices. */
|
||||
export function draftValue(field: StateField, raw: string): number | string {
|
||||
return field.kind === "level" ? Number(raw) : raw;
|
||||
}
|
||||
|
||||
export function isFieldChanged(field: StateField, raw: string, current: number | string | undefined): boolean {
|
||||
if (field.kind === "choice") return raw !== String(current ?? defaultText(field));
|
||||
return Math.abs(Number(raw) - Number(current ?? 0)) > LEVEL_TOLERANCE;
|
||||
}
|
||||
|
||||
interface StateFieldControlProps {
|
||||
field: StateField;
|
||||
idPrefix: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function LevelControl({ field, idPrefix, value, onChange }: StateFieldControlProps & { field: LevelField }) {
|
||||
const id = `${idPrefix}-${field.key}`;
|
||||
const problem = levelProblem(value, field);
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{field.label}
|
||||
</label>
|
||||
<NumberStepper
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={0}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
decimals={decimalsOf(field.step)}
|
||||
unitLabel={field.unit}
|
||||
decrementLabel={`${field.label} verringern`}
|
||||
incrementLabel={`${field.label} erhöhen`}
|
||||
invalid={problem !== null}
|
||||
describedBy={problem ? `${id}-problem` : undefined}
|
||||
/>
|
||||
{problem ? (
|
||||
<p id={`${id}-problem`} className="text-xs text-destructive">
|
||||
{problem}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceControl({ field, idPrefix, value, onChange }: StateFieldControlProps & { field: ChoiceField }) {
|
||||
const id = `${idPrefix}-${field.key}`;
|
||||
const options = field.options.map((option, index) => ({
|
||||
value: option,
|
||||
label: field.optionLabels[index] ?? option,
|
||||
}));
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span id={`${id}-label`} className="text-sm font-medium">
|
||||
{field.label}
|
||||
</span>
|
||||
<ChoiceGroup
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
labelledBy={`${id}-label`}
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One control for one `stateSchema` field: a stepper for levels, a choice group for choices. */
|
||||
export function StateFieldControl(props: StateFieldControlProps) {
|
||||
const { field } = props;
|
||||
return field.kind === "level" ? (
|
||||
<LevelControl {...props} field={field} />
|
||||
) : (
|
||||
<ChoiceControl {...props} field={field} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type {
|
||||
HiveChangeListItem,
|
||||
HiveSetupView,
|
||||
HiveState,
|
||||
HiveSystemDto,
|
||||
} from "@/lib/hive/types";
|
||||
import type { Colony } from "@/lib/colonies/types";
|
||||
|
||||
// Shared by the hive card, init and history specs.
|
||||
|
||||
export const SYSTEM_ID = "sys";
|
||||
|
||||
export function systemDto(): HiveSystemDto {
|
||||
const catalog = inlineCatalog();
|
||||
return {
|
||||
id: SYSTEM_ID,
|
||||
name: "Zander nach Dr. Liebig",
|
||||
frameWidthMm: 477,
|
||||
frameHeightMm: 220,
|
||||
footprintLMm: 525,
|
||||
footprintWMm: 430,
|
||||
template: {},
|
||||
active: true,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
updatedAt: "2026-10-01T00:00:00.000Z",
|
||||
partTypes: [...catalog.partTypes.values()].map((row) => ({
|
||||
...row,
|
||||
systemId: SYSTEM_ID,
|
||||
articleRef: null,
|
||||
boxDepth: null,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
updatedAt: "2026-10-01T00:00:00.000Z",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Two boxes with 3 brood frames each, two feeders with 5 l together, a floor insert. */
|
||||
export function sampleState(): HiveState {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
|
||||
part("b1", "box", { frames: frames(10).map((frame) => (frame.slot <= 3 ? { ...frame, type: "BROOD" as const } : frame)) }),
|
||||
part("b2", "box", { frames: frames(10).map((frame) => (frame.slot <= 3 ? { ...frame, type: "BROOD" as const } : frame)) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 3, fondantKg: 0 } }),
|
||||
part("fe2", "feeder", { state: { syrupLiters: 2, fondantKg: 0 } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function setupView(state: HiveState = sampleState(), colonyId = "colony-1", version = 3): HiveSetupView {
|
||||
return {
|
||||
colonyId,
|
||||
systemId: SYSTEM_ID,
|
||||
version,
|
||||
state,
|
||||
facts: deriveHiveFacts(state, inlineCatalog()),
|
||||
updatedAt: "2026-10-12T10:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
export function colonyFixture(extra: Partial<Colony> = {}): Colony {
|
||||
return {
|
||||
id: "colony-1",
|
||||
code: "V-01",
|
||||
apiaryId: "apiary-1",
|
||||
hiveType: null,
|
||||
originText: null,
|
||||
colonyType: "PRODUCTION_COLONY",
|
||||
status: "ACTIVE",
|
||||
strength: null,
|
||||
occupiedFrames: null,
|
||||
broodStatus: null,
|
||||
feedStatus: null,
|
||||
healthStatus: null,
|
||||
notes: null,
|
||||
currentQueenId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
deactivatedAt: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export function historyItem(extra: Partial<HiveChangeListItem> = {}): HiveChangeListItem {
|
||||
return {
|
||||
id: "c1",
|
||||
version: 3,
|
||||
occurredOn: "2026-10-12",
|
||||
source: "INSPECTION",
|
||||
inspectionId: "i1",
|
||||
userId: "u1",
|
||||
userName: "Nick",
|
||||
ops: [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }],
|
||||
note: null,
|
||||
createdAt: "2026-10-12T12:00:00.000Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { hiveKeys, postHiveChange } from "@/lib/hive/api";
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
|
||||
import type { HiveOp, HiveSetupView, HiveState } from "@/lib/hive/types";
|
||||
import { fetchColonyTreatments } from "@/lib/treatments/api";
|
||||
import { HIVE_BUSY_MESSAGE, useHiveMutation } from "./use-hive-mutation";
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push }) }));
|
||||
vi.mock("sonner", () => ({ toast: vi.fn() }));
|
||||
vi.mock("@/lib/treatments/api", () => ({ fetchColonyTreatments: vi.fn() }));
|
||||
vi.mock("@/lib/hive/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/hive/api")>()),
|
||||
postHiveChange: vi.fn(),
|
||||
}));
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
const COLONY = "colony-1";
|
||||
|
||||
const state = (feederLevel = 2): HiveState => ({
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b", "box", { frames: frames(10) }),
|
||||
part("fe", "feeder", { state: { syrupLiters: feederLevel, fondantKg: 0 } }),
|
||||
part("r", "roof"),
|
||||
],
|
||||
});
|
||||
|
||||
const view = (s: HiveState, version = 1): HiveSetupView => ({
|
||||
colonyId: COLONY,
|
||||
systemId: "sys",
|
||||
version,
|
||||
state: s,
|
||||
facts: deriveHiveFacts(s, catalog),
|
||||
updatedAt: "2026-10-15T10:00:00.000Z",
|
||||
});
|
||||
|
||||
const refill: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }];
|
||||
|
||||
function conflictError(current: HiveState, reason = "MISSING_ID: no part with id x"): ApiError {
|
||||
return new ApiError(409, "Konflikt", {
|
||||
code: "HIVE_CONFLICT",
|
||||
opIndex: 0,
|
||||
reason,
|
||||
current: { version: 5, state: current },
|
||||
});
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
client.setQueryData(hiveKeys.detail(COLONY), view(state()));
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
);
|
||||
const hook = renderHook(() => useHiveMutation(COLONY, catalog), { wrapper });
|
||||
const cached = () => client.getQueryData<HiveSetupView>(hiveKeys.detail(COLONY));
|
||||
return { client, hook, cached };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(postHiveChange).mockReset();
|
||||
vi.mocked(toast).mockReset();
|
||||
vi.mocked(fetchColonyTreatments).mockReset().mockResolvedValue([]);
|
||||
push.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useHiveMutation", () => {
|
||||
it("shows the result in the cache before the server answers, then stores the answer", async () => {
|
||||
let resolve: (v: HiveSetupView) => void = () => undefined;
|
||||
vi.mocked(postHiveChange).mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const { hook, cached } = setup();
|
||||
|
||||
let pending: Promise<unknown> = Promise.resolve();
|
||||
act(() => {
|
||||
pending = hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
await waitFor(() => expect(cached()?.state.stack[2].state.syrupLiters).toBe(3));
|
||||
expect(cached()?.version).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
resolve(view(state(3), 2));
|
||||
await pending;
|
||||
});
|
||||
expect(cached()?.version).toBe(2);
|
||||
expect(vi.mocked(postHiveChange).mock.calls[0][1]).toEqual({
|
||||
occurredOn: "2026-10-15",
|
||||
source: "CARD",
|
||||
ops: refill,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults occurredOn to the Berlin day", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-10-15T22:30:00.000Z")); // already 16 Oct in Berlin
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD");
|
||||
});
|
||||
expect(vi.mocked(postHiveChange).mock.calls[0][1].occurredOn).toBe("2026-10-16");
|
||||
});
|
||||
|
||||
it("refetches instead of restoring a snapshot on a plain error, and rethrows", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(new ApiError(500, "kaputt", null));
|
||||
const { hook, client } = setup();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
await act(async () => {
|
||||
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow("kaputt");
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: hiveKeys.detail(COLONY) });
|
||||
expect(hook.result.current.conflict).toBeNull();
|
||||
});
|
||||
|
||||
it("restores the previous state when the request fails offline", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
const { hook, cached } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
expect(cached()?.state.stack[2].state.syrupLiters).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps a newer cache value instead of restoring the snapshot", async () => {
|
||||
let reject: (e: unknown) => void = () => undefined;
|
||||
vi.mocked(postHiveChange).mockReturnValue(new Promise((_, r) => (reject = r)));
|
||||
const { hook, cached, client } = setup();
|
||||
let pending: Promise<unknown> = Promise.resolve();
|
||||
act(() => {
|
||||
pending = hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await waitFor(() => expect(cached()?.state.stack[2].state.syrupLiters).toBe(3));
|
||||
client.setQueryData(hiveKeys.detail(COLONY), view(state(1), 9));
|
||||
await act(async () => {
|
||||
reject(new TypeError("Failed to fetch"));
|
||||
await pending;
|
||||
});
|
||||
expect(cached()?.version).toBe(9);
|
||||
});
|
||||
|
||||
it("uses a fresh key after a 4xx failure", async () => {
|
||||
vi.mocked(postHiveChange)
|
||||
.mockRejectedValueOnce(new ApiError(400, "falsch", null))
|
||||
.mockResolvedValueOnce(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
const calls = vi.mocked(postHiveChange).mock.calls;
|
||||
expect(calls[1][2]).not.toBe(calls[0][2]);
|
||||
});
|
||||
|
||||
it("reuses the idempotency key when the same submit is pressed again after a network error", async () => {
|
||||
vi.mocked(postHiveChange)
|
||||
.mockRejectedValueOnce(new ApiError(503, "weg", null))
|
||||
.mockResolvedValueOnce(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
const calls = vi.mocked(postHiveChange).mock.calls;
|
||||
expect(calls[1][2]).toBe(calls[0][2]);
|
||||
});
|
||||
|
||||
it("uses a fresh key for different ops and after success", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
const calls = vi.mocked(postHiveChange).mock.calls;
|
||||
expect(calls[1][2]).not.toBe(calls[0][2]);
|
||||
});
|
||||
|
||||
it("rejects a second submit while one is running", async () => {
|
||||
let resolve: (v: HiveSetupView) => void = () => undefined;
|
||||
vi.mocked(postHiveChange).mockReturnValue(new Promise((r) => (resolve = r)));
|
||||
const { hook } = setup();
|
||||
let first: Promise<unknown> = Promise.resolve();
|
||||
act(() => {
|
||||
first = hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
await act(async () => {
|
||||
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toThrow(HIVE_BUSY_MESSAGE);
|
||||
});
|
||||
await act(async () => {
|
||||
resolve(view(state(3), 2));
|
||||
await first;
|
||||
});
|
||||
expect(vi.mocked(postHiveChange)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels a running detail refetch before the optimistic write", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook, client } = setup();
|
||||
const spy = vi.spyOn(client, "cancelQueries");
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: hiveKeys.detail(COLONY) });
|
||||
});
|
||||
|
||||
it("invalidates the history after success", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook, client } = setup();
|
||||
const spy = vi.spyOn(client, "invalidateQueries");
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
expect(spy).toHaveBeenCalledWith({ queryKey: hiveKeys.history(COLONY) });
|
||||
});
|
||||
|
||||
it("suggests a feeding with the prefilled link, never booking one", async () => {
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(3), 2));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15");
|
||||
});
|
||||
const [message, options] = vi.mocked(toast).mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ action: { onClick: () => void } },
|
||||
];
|
||||
expect(message).toBe("1 l Flüssigfutter nachgefüllt. Als Fütterung eintragen?");
|
||||
options.action.onClick();
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
"/feedings/new?colonyId=colony-1&date=2026-10-15&amount=1&unit=l&feedType=SUGAR_WATER",
|
||||
);
|
||||
});
|
||||
|
||||
it("suggests a treatment when an evaporator with acid is inserted", async () => {
|
||||
const withEvaporator = state();
|
||||
withEvaporator.stack[1] = {
|
||||
...withEvaporator.stack[1],
|
||||
inserts: [part("ev", "evaporator", { state: { acidMl: 250 } })],
|
||||
};
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(withEvaporator, 2));
|
||||
const { hook } = setup();
|
||||
const ops: HiveOp[] = [
|
||||
{ op: "addInsert", hostId: "b", part: part("ev", "evaporator", { state: { acidMl: 250 } }) },
|
||||
];
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(ops, "CARD", "2026-10-15");
|
||||
});
|
||||
const [message, options] = vi.mocked(toast).mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ action: { onClick: () => void } },
|
||||
];
|
||||
expect(message).toBe("Verdunster mit 250 ml eingesetzt. Als Behandlung eintragen?");
|
||||
options.action.onClick();
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
"/treatments/new?colonyId=colony-1&date=2026-10-15&amount=250&unit=ml",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays silent when an evaporator is removed and no treatment is open", async () => {
|
||||
const before = state();
|
||||
before.stack[1] = { ...before.stack[1], inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })] };
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(), 2));
|
||||
vi.mocked(fetchColonyTreatments).mockResolvedValue([{ id: "t", endDate: "2026-10-01" }] as never);
|
||||
const { hook, client } = setup();
|
||||
client.setQueryData(hiveKeys.detail(COLONY), view(before));
|
||||
await act(async () => {
|
||||
await hook.result.current.submit([{ op: "removePart", id: "ev" }], "CARD", "2026-10-15");
|
||||
});
|
||||
await waitFor(() => expect(fetchColonyTreatments).toHaveBeenCalled());
|
||||
await act(async () => {});
|
||||
expect(toast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks whether to end the treatment when an evaporator is removed and one is open", async () => {
|
||||
const before = state();
|
||||
before.stack[1] = { ...before.stack[1], inserts: [part("ev", "evaporator", { state: { acidMl: 100 } })] };
|
||||
vi.mocked(postHiveChange).mockResolvedValue(view(state(), 2));
|
||||
const { hook, client } = setup();
|
||||
vi.mocked(fetchColonyTreatments).mockResolvedValue([{ id: "t", endDate: null }] as never);
|
||||
client.setQueryData(hiveKeys.detail(COLONY), view(before));
|
||||
await act(async () => {
|
||||
await hook.result.current.submit([{ op: "removePart", id: "ev" }], "CARD", "2026-10-15");
|
||||
});
|
||||
await waitFor(() => expect(toast).toHaveBeenCalled());
|
||||
const [message, options] = vi.mocked(toast).mock.calls[0] as unknown as [
|
||||
string,
|
||||
{ action: { onClick: () => void } },
|
||||
];
|
||||
expect(message).toBe("Behandlung beenden?");
|
||||
options.action.onClick();
|
||||
expect(push).toHaveBeenCalledWith("/colonies/colony-1#behandlungen");
|
||||
});
|
||||
|
||||
describe("on 409 HIVE_CONFLICT", () => {
|
||||
const serverState = (): HiveState => {
|
||||
const s = state(1);
|
||||
return s;
|
||||
};
|
||||
|
||||
it("keeps own ops, the current state and a preview, and shows the server state", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(conflictError(serverState()));
|
||||
const { hook, cached } = setup();
|
||||
await act(async () => {
|
||||
await expect(hook.result.current.submit(refill, "CARD", "2026-10-15")).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
const conflict = hook.result.current.conflict;
|
||||
expect(conflict?.ops).toEqual(refill);
|
||||
expect(conflict?.current.version).toBe(5);
|
||||
expect(conflict?.preview).toMatchObject({ ok: true });
|
||||
if (conflict?.preview.ok) expect(conflict.preview.state.stack[2].state.syrupLiters).toBe(3);
|
||||
expect(cached()?.version).toBe(5);
|
||||
expect(cached()?.state.stack[2].state.syrupLiters).toBe(1);
|
||||
});
|
||||
|
||||
it("reports a failing preview with op index and reason", async () => {
|
||||
const gone = serverState();
|
||||
gone.stack = gone.stack.filter((p) => p.id !== "fe");
|
||||
vi.mocked(postHiveChange).mockRejectedValue(conflictError(gone));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
const preview = hook.result.current.conflict?.preview;
|
||||
expect(preview?.ok).toBe(false);
|
||||
if (preview && !preview.ok) {
|
||||
expect(preview.opIndex).toBe(0);
|
||||
expect(preview.reason).toMatch(/MISSING_ID/);
|
||||
}
|
||||
});
|
||||
|
||||
it("retry resubmits the ops with a new idempotency key", async () => {
|
||||
vi.mocked(postHiveChange)
|
||||
.mockRejectedValueOnce(conflictError(serverState()))
|
||||
.mockResolvedValueOnce(view(state(3), 6));
|
||||
const { hook, cached } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.resolveConflict("retry");
|
||||
});
|
||||
const calls = vi.mocked(postHiveChange).mock.calls;
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1][1]).toEqual({ occurredOn: "2026-10-15", source: "CARD", ops: refill });
|
||||
expect(calls[1][2]).not.toBe(calls[0][2]);
|
||||
expect(hook.result.current.conflict).toBeNull();
|
||||
expect(cached()?.version).toBe(6);
|
||||
});
|
||||
|
||||
it("does not resubmit when the preview failed", async () => {
|
||||
const gone = serverState();
|
||||
gone.stack = gone.stack.filter((p) => p.id !== "fe");
|
||||
vi.mocked(postHiveChange).mockRejectedValue(conflictError(gone));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.resolveConflict("retry");
|
||||
});
|
||||
expect(vi.mocked(postHiveChange)).toHaveBeenCalledTimes(1);
|
||||
expect(hook.result.current.conflict).not.toBeNull();
|
||||
});
|
||||
|
||||
it("discard clears the conflict", async () => {
|
||||
vi.mocked(postHiveChange).mockRejectedValue(conflictError(serverState()));
|
||||
const { hook } = setup();
|
||||
await act(async () => {
|
||||
await hook.result.current.submit(refill, "CARD", "2026-10-15").catch(() => undefined);
|
||||
});
|
||||
await act(async () => {
|
||||
await hook.result.current.resolveConflict("discard");
|
||||
});
|
||||
await waitFor(() => expect(hook.result.current.conflict).toBeNull());
|
||||
expect(vi.mocked(postHiveChange)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { berlinCalendarDay } from "@/lib/dates";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import { hiveConflictOf, hiveKeys, postHiveChange } from "@/lib/hive/api";
|
||||
import { deriveHiveFacts } from "@/lib/hive/facts";
|
||||
import { applyHiveOps } from "@/lib/hive/ops";
|
||||
import { fetchColonyTreatments } from "@/lib/treatments/api";
|
||||
import { newIdempotencyKey } from "@/lib/offline/idempotency";
|
||||
import type {
|
||||
ClientHiveChangeSource,
|
||||
HiveApplyResult,
|
||||
HiveCatalog,
|
||||
HiveOp,
|
||||
HiveSetupView,
|
||||
HiveState,
|
||||
} from "@/lib/hive/types";
|
||||
import { showHiveFollowUps } from "./follow-up-toast";
|
||||
|
||||
/** The server refused the ops against its newer state (409 HIVE_CONFLICT). */
|
||||
export interface HiveConflict {
|
||||
/** The state the server holds now. */
|
||||
current: { version: number; state: HiveState };
|
||||
/** The user's own ops. */
|
||||
ops: HiveOp[];
|
||||
/** The ops re-applied to `current.state`; a failure carries op index and reason. */
|
||||
preview: HiveApplyResult;
|
||||
}
|
||||
|
||||
export interface HiveMutation {
|
||||
submit(ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string): Promise<HiveSetupView>;
|
||||
conflict: HiveConflict | null;
|
||||
/** "retry" sends the ops again against the current state (new key); "discard" drops them. */
|
||||
resolveConflict(action: "retry" | "discard"): Promise<void>;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
interface Attempt {
|
||||
ops: HiveOp[];
|
||||
source: ClientHiveChangeSource;
|
||||
occurredOn: string;
|
||||
}
|
||||
|
||||
/** Thrown for a submit while another one of this hook is still running. */
|
||||
export const HIVE_BUSY_MESSAGE = "HIVE_BUSY";
|
||||
|
||||
/** Same ops, source and day: a manual re-press after a network error. */
|
||||
function signatureOf(attempt: Attempt): string {
|
||||
return JSON.stringify([attempt.source, attempt.occurredOn, attempt.ops]);
|
||||
}
|
||||
|
||||
/** No verdict from the server (network, timeout, 5xx): the change may or may not be saved. */
|
||||
function isAmbiguousFailure(error: unknown): boolean {
|
||||
return !(error instanceof ApiError) || error.status >= 500;
|
||||
}
|
||||
|
||||
function withState(view: HiveSetupView, state: HiveState, version: number, catalog: HiveCatalog): HiveSetupView {
|
||||
return { ...view, version, state, facts: deriveHiveFacts(state, catalog) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a change of the colony's hive. The cache shows the result at once
|
||||
* (optimistic); a failure refetches it. One submit at a time. An attempt keeps
|
||||
* its idempotency key across manual re-presses after a network error or 5xx;
|
||||
* a retry after a conflict gets a fresh one. Follow-up toasts are suggestions only.
|
||||
*/
|
||||
export function useHiveMutation(colonyId: string, catalog: HiveCatalog): HiveMutation {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const [conflict, setConflict] = useState<HiveConflict | null>(null);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const lastAttempt = useRef<Attempt | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
// Key of an attempt that failed without a verdict (network, 5xx): a re-press reuses it.
|
||||
const failedKey = useRef<{ signature: string; key: string } | null>(null);
|
||||
const queryKey = hiveKeys.detail(colonyId);
|
||||
|
||||
const hasOpenTreatment = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const treatments = await queryClient.fetchQuery({
|
||||
queryKey: ["colonies", colonyId, "treatments"],
|
||||
queryFn: () => fetchColonyTreatments(colonyId),
|
||||
});
|
||||
return treatments.some((treatment) => treatment.endDate == null);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [colonyId, queryClient]);
|
||||
|
||||
const suggestFollowUps = useCallback(
|
||||
async (attempt: Attempt, before: HiveState, after: HiveState): Promise<void> => {
|
||||
const { ops, occurredOn } = attempt;
|
||||
const open = ops.some((op) => op.op === "removePart") ? await hasOpenTreatment() : false;
|
||||
showHiveFollowUps(ops, before, after, catalog, {
|
||||
colonyId,
|
||||
occurredOn,
|
||||
navigate: (href) => router.push(href),
|
||||
hasOpenTreatment: open,
|
||||
});
|
||||
},
|
||||
[catalog, colonyId, hasOpenTreatment, router],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (attempt: Attempt): Promise<HiveSetupView> => {
|
||||
if (inFlight.current) throw new Error(HIVE_BUSY_MESSAGE);
|
||||
inFlight.current = true;
|
||||
const { ops, source, occurredOn } = attempt;
|
||||
const signature = signatureOf(attempt);
|
||||
const key =
|
||||
failedKey.current?.signature === signature ? failedKey.current.key : newIdempotencyKey();
|
||||
setPendingCount((count) => count + 1);
|
||||
try {
|
||||
// A running refetch must not overwrite the optimistic state.
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
const previous = queryClient.getQueryData<HiveSetupView>(queryKey);
|
||||
if (previous) {
|
||||
const applied = applyHiveOps(previous.state, ops, catalog);
|
||||
if (applied.ok) {
|
||||
queryClient.setQueryData(queryKey, withState(previous, applied.state, previous.version, catalog));
|
||||
}
|
||||
}
|
||||
let optimistic: HiveSetupView | undefined;
|
||||
if (previous) optimistic = queryClient.getQueryData<HiveSetupView>(queryKey);
|
||||
try {
|
||||
const view = await postHiveChange(colonyId, { occurredOn, source, ops }, key);
|
||||
queryClient.setQueryData(queryKey, view);
|
||||
setConflict(null);
|
||||
lastAttempt.current = null;
|
||||
failedKey.current = null;
|
||||
void queryClient.invalidateQueries({ queryKey: hiveKeys.history(colonyId) });
|
||||
if (previous) {
|
||||
// Follow-ups are best effort: a failing suggestion must not fail the saved change.
|
||||
suggestFollowUps(attempt, previous.state, view.state).catch(() => {});
|
||||
}
|
||||
return view;
|
||||
} catch (error) {
|
||||
const body = hiveConflictOf(error);
|
||||
if (body) {
|
||||
failedKey.current = null;
|
||||
lastAttempt.current = attempt;
|
||||
if (previous) {
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
withState(previous, body.current.state, body.current.version, catalog),
|
||||
);
|
||||
}
|
||||
setConflict({
|
||||
current: body.current,
|
||||
ops,
|
||||
preview: applyHiveOps(body.current.state, ops, catalog),
|
||||
});
|
||||
} else {
|
||||
failedKey.current = isAmbiguousFailure(error) ? { signature, key } : null;
|
||||
// Restore only if nobody changed the cache since our optimistic write; then refetch.
|
||||
if (previous && optimistic && queryClient.getQueryData(queryKey) === optimistic) {
|
||||
queryClient.setQueryData(queryKey, previous);
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
setPendingCount((count) => count - 1);
|
||||
}
|
||||
},
|
||||
[catalog, colonyId, queryClient, queryKey, suggestFollowUps],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
(ops: HiveOp[], source: ClientHiveChangeSource, occurredOn?: string) =>
|
||||
run({ ops, source, occurredOn: occurredOn ?? berlinCalendarDay(new Date().toISOString()) }),
|
||||
[run],
|
||||
);
|
||||
|
||||
const resolveConflict = useCallback(
|
||||
async (action: "retry" | "discard"): Promise<void> => {
|
||||
const attempt = lastAttempt.current;
|
||||
if (action === "discard" || !attempt) {
|
||||
lastAttempt.current = null;
|
||||
setConflict(null);
|
||||
return;
|
||||
}
|
||||
if (conflict && !conflict.preview.ok) return;
|
||||
try {
|
||||
await run(attempt);
|
||||
} catch (error) {
|
||||
// A new conflict is already in `conflict`; anything else is for the caller.
|
||||
if (!hiveConflictOf(error)) throw error;
|
||||
}
|
||||
},
|
||||
[conflict, run],
|
||||
);
|
||||
|
||||
return { submit, conflict, resolveConflict, isPending: pendingCount > 0 };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { catalogFromSystem } from "@/lib/hive/facts";
|
||||
import { fetchHiveSystems, hiveKeys } from "@/lib/hive/api";
|
||||
import type { HiveCatalog, HiveSystemDto } from "@/lib/hive/types";
|
||||
|
||||
/** The shared systems query (catalog of every system); the card and the init flow read the same cache. */
|
||||
export function useHiveSystems() {
|
||||
return useQuery({ queryKey: hiveKeys.systems, queryFn: fetchHiveSystems });
|
||||
}
|
||||
|
||||
/** Catalog of one system, stable while the systems data stays the same. */
|
||||
export function useSystemCatalog(
|
||||
systems: HiveSystemDto[] | undefined,
|
||||
systemId: string | undefined,
|
||||
): HiveCatalog | null {
|
||||
return useMemo(() => {
|
||||
const system = systems?.find((candidate) => candidate.id === systemId);
|
||||
return system ? catalogFromSystem(system) : null;
|
||||
}, [systems, systemId]);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createHiveViewStore, HiveViewProvider, useHiveView } from "./use-hive-view";
|
||||
|
||||
describe("createHiveViewStore", () => {
|
||||
it("starts empty and keeps the same state object until something changes", () => {
|
||||
const store = createHiveViewStore();
|
||||
const first = store.getState();
|
||||
expect(first).toEqual({ hoveredId: null, openBoxId: null, hoveredFrame: null });
|
||||
store.setHovered(null);
|
||||
expect(store.getState()).toBe(first);
|
||||
store.setHovered("a");
|
||||
expect(store.getState()).not.toBe(first);
|
||||
expect(store.getState().hoveredId).toBe("a");
|
||||
});
|
||||
|
||||
it("notifies subscribers only on real changes and supports unsubscribe", () => {
|
||||
const store = createHiveViewStore();
|
||||
const listener = vi.fn();
|
||||
const off = store.subscribe(listener);
|
||||
store.setHovered("a");
|
||||
store.setHovered("a");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
off();
|
||||
store.setHovered("b");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opening a box clears the hovered frame", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.setHoveredFrame({ boxId: "b", slot: 3 });
|
||||
store.openBox("b");
|
||||
expect(store.getState()).toMatchObject({ openBoxId: "b", hoveredFrame: null });
|
||||
});
|
||||
|
||||
it("openBox(null) clears the open box and the hovered frame", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.openBox("b");
|
||||
store.setHoveredFrame({ boxId: "b", slot: 2 });
|
||||
store.openBox(null);
|
||||
expect(store.getState()).toMatchObject({ openBoxId: null, hoveredFrame: null });
|
||||
});
|
||||
|
||||
it("treats an equal frame as no change", () => {
|
||||
const store = createHiveViewStore();
|
||||
store.setHoveredFrame({ boxId: "b", slot: 2 });
|
||||
const before = store.getState();
|
||||
store.setHoveredFrame({ boxId: "b", slot: 2 });
|
||||
expect(store.getState()).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useHiveView", () => {
|
||||
it("exposes state and setters from the provider and re-renders on change", () => {
|
||||
const store = createHiveViewStore();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<HiveViewProvider store={store}>{children}</HiveViewProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useHiveView(), { wrapper });
|
||||
expect(result.current.hoveredId).toBeNull();
|
||||
act(() => result.current.setHovered("x"));
|
||||
expect(result.current.hoveredId).toBe("x");
|
||||
act(() => result.current.openBox("b"));
|
||||
expect(result.current.openBoxId).toBe("b");
|
||||
});
|
||||
|
||||
it("throws outside a provider", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => renderHook(() => useHiveView())).toThrow(/HiveViewProvider/);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, createElement, useContext, useMemo, useSyncExternalStore } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// View state shared by the outline, the SVG and the 3D scene. It lives outside
|
||||
// three.js so every surface sees the same hover and the same open box (spec 9.1).
|
||||
|
||||
export interface HoveredFrame {
|
||||
boxId: string;
|
||||
slot: number;
|
||||
}
|
||||
|
||||
export interface HiveViewState {
|
||||
hoveredId: string | null;
|
||||
openBoxId: string | null;
|
||||
hoveredFrame: HoveredFrame | null;
|
||||
}
|
||||
|
||||
export interface HiveViewStore {
|
||||
getState(): HiveViewState;
|
||||
subscribe(listener: () => void): () => void;
|
||||
setHovered(id: string | null): void;
|
||||
/** Opens a box (clears the hovered frame); null closes it and clears the hovered frame too. */
|
||||
openBox(id: string | null): void;
|
||||
setHoveredFrame(frame: HoveredFrame | null): void;
|
||||
}
|
||||
|
||||
export interface HiveView extends HiveViewState {
|
||||
setHovered(id: string | null): void;
|
||||
openBox(id: string | null): void;
|
||||
setHoveredFrame(frame: HoveredFrame | null): void;
|
||||
}
|
||||
|
||||
const sameFrame = (a: HoveredFrame | null, b: HoveredFrame | null): boolean =>
|
||||
a === b || (a !== null && b !== null && a.boxId === b.boxId && a.slot === b.slot);
|
||||
|
||||
export function createHiveViewStore(): HiveViewStore {
|
||||
let state: HiveViewState = { hoveredId: null, openBoxId: null, hoveredFrame: null };
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
// The state object only changes when a value really changes, so
|
||||
// useSyncExternalStore gets a stable snapshot.
|
||||
const update = (patch: Partial<HiveViewState>): void => {
|
||||
const next = { ...state, ...patch };
|
||||
if (
|
||||
next.hoveredId === state.hoveredId &&
|
||||
next.openBoxId === state.openBoxId &&
|
||||
sameFrame(next.hoveredFrame, state.hoveredFrame)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state = next;
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
setHovered: (id) => update({ hoveredId: id }),
|
||||
openBox: (id) => update({ openBoxId: id, hoveredFrame: null }),
|
||||
setHoveredFrame: (frame) => update({ hoveredFrame: frame }),
|
||||
};
|
||||
}
|
||||
|
||||
const HiveViewContext = createContext<HiveViewStore | null>(null);
|
||||
|
||||
export function HiveViewProvider({ store, children }: { store: HiveViewStore; children: ReactNode }) {
|
||||
return createElement(HiveViewContext.Provider, { value: store }, children);
|
||||
}
|
||||
|
||||
export function useHiveView(): HiveView {
|
||||
const store = useContext(HiveViewContext);
|
||||
if (!store) throw new Error("useHiveView must be used inside a HiveViewProvider");
|
||||
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
||||
return useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
setHovered: store.setHovered,
|
||||
openBox: store.openBox,
|
||||
setHoveredFrame: store.setHoveredFrame,
|
||||
}),
|
||||
[state, store],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { hiveConflictOf } from "@/lib/hive/api";
|
||||
import type { HiveOp } from "@/lib/hive/types";
|
||||
|
||||
export const SAVE_FAILED_TEXT = "Speichern fehlgeschlagen. Bitte noch einmal versuchen.";
|
||||
|
||||
/** A refusal with a German sentence the sheet may show as it is (e.g. a draft that breaks a rule). */
|
||||
export class HiveDraftError extends Error {}
|
||||
|
||||
export type SubmitOps = (ops: HiveOp[]) => Promise<unknown>;
|
||||
|
||||
export type SubmitOutcome = "saved" | "conflict" | "failed";
|
||||
|
||||
/**
|
||||
* Sends ops for a sheet. On a conflict the caller closes the sheet (the
|
||||
* conflict sheet takes over, so sheets never stack); other failures stay inline.
|
||||
*/
|
||||
export function useSheetSubmit(submit: SubmitOps) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const send = useCallback(
|
||||
async (ops: HiveOp[]): Promise<SubmitOutcome> => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await submit(ops);
|
||||
return "saved";
|
||||
} catch (failure) {
|
||||
if (hiveConflictOf(failure)) return "conflict";
|
||||
setError(failure instanceof HiveDraftError ? failure.message : SAVE_FAILED_TEXT);
|
||||
return "failed";
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
},
|
||||
[submit],
|
||||
);
|
||||
|
||||
return { send, pending, error };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { toApiDate } from "@/lib/dates";
|
||||
|
||||
/** The read side of URLSearchParams / Next's ReadonlyURLSearchParams. */
|
||||
export interface ParamSource {
|
||||
get(name: string): string | null;
|
||||
}
|
||||
|
||||
const MAX_AMOUNT = 1_000_000;
|
||||
|
||||
/** A real calendar day "YYYY-MM-DD", else null (invalid links are ignored, never fatal). */
|
||||
export function prefillDay(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
toApiDate(raw);
|
||||
return raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A positive, finite number as a dot-decimal string ("2.5"), else null. */
|
||||
export function prefillAmount(raw: string | null): string | null {
|
||||
if (!raw || !/^\d+(\.\d+)?$/.test(raw)) return null;
|
||||
const value = Number(raw);
|
||||
return value > 0 && value <= MAX_AMOUNT ? String(value) : null;
|
||||
}
|
||||
@@ -54,3 +54,20 @@ describe.each([
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["light", ":root"],
|
||||
["dark", ".dark"],
|
||||
])("hive label in %s mode", (_mode, selector) => {
|
||||
it(`holo label text on label background is at least ${MIN_CONTRAST}:1`, () => {
|
||||
const scope = block(selector);
|
||||
const ratio = contrast(oklch(scope, "holo-label-fg"), oklch(scope, "holo-label-bg"));
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
|
||||
});
|
||||
|
||||
it(`holo edge on the screen background is at least ${MIN_CONTRAST}:1`, () => {
|
||||
const scope = block(selector);
|
||||
const ratio = contrast(oklch(scope, "holo-edge"), oklch(scope, "holo-bg"));
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CONTRAST);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
createHive,
|
||||
fetchHive,
|
||||
fetchHiveAt,
|
||||
fetchHiveHistory,
|
||||
fetchHiveSystems,
|
||||
fetchHiveTemplate,
|
||||
hiveConflictOf,
|
||||
hiveErrorCode,
|
||||
hiveKeys,
|
||||
postHiveChange,
|
||||
} from "./api";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@/lib/auth/api-client")>();
|
||||
return { ...original, apiFetch: vi.fn() };
|
||||
});
|
||||
|
||||
describe("hive api", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("reads systems, setup, template, history and a day", async () => {
|
||||
await fetchHiveSystems();
|
||||
await fetchHive("c 1");
|
||||
await fetchHiveTemplate("s1");
|
||||
await fetchHiveHistory("c1");
|
||||
await fetchHiveHistory("c1", "abc_-");
|
||||
await fetchHiveAt("c1", "2026-10-01");
|
||||
expect(vi.mocked(apiFetch).mock.calls.map((call) => call[0])).toEqual([
|
||||
"/hive-systems",
|
||||
"/colonies/c%201/hive",
|
||||
"/hive-systems/s1/template",
|
||||
"/colonies/c1/hive/changes",
|
||||
"/colonies/c1/hive/changes?cursor=abc_-",
|
||||
"/colonies/c1/hive/at?day=2026-10-01",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends the idempotency key on create and change", async () => {
|
||||
const state = { schemaVersion: 1 as const, stack: [] };
|
||||
await createHive("c1", { systemId: "s", state, occurredOn: "2026-10-01" }, "key-1");
|
||||
await postHiveChange("c1", { occurredOn: "2026-10-01", source: "CARD", ops: [] }, "key-2");
|
||||
const [create, change] = vi.mocked(apiFetch).mock.calls;
|
||||
expect(create[0]).toBe("/colonies/c1/hive");
|
||||
expect(create[1]).toMatchObject({ method: "POST", headers: { "Idempotency-Key": "key-1" } });
|
||||
expect(change[0]).toBe("/colonies/c1/hive/changes");
|
||||
expect(change[1]).toMatchObject({ method: "POST", headers: { "Idempotency-Key": "key-2" } });
|
||||
});
|
||||
|
||||
it("has the query keys of the plan", () => {
|
||||
expect(hiveKeys.systems).toEqual(["hive-systems"]);
|
||||
expect(hiveKeys.detail("c1")).toEqual(["colonies", "c1", "hive"]);
|
||||
expect(hiveKeys.history("c1")).toEqual(["colonies", "c1", "hive", "history"]);
|
||||
});
|
||||
|
||||
it("extracts the HIVE_CONFLICT body and error codes", () => {
|
||||
const body = { code: "HIVE_CONFLICT", opIndex: 1, reason: "RULE_ROOF_TOP: x", current: { version: 3, state: {} } };
|
||||
expect(hiveConflictOf(new ApiError(409, "Conflict", body))).toEqual(body);
|
||||
expect(hiveConflictOf(new ApiError(409, "Conflict", { code: "HIVE_EXISTS" }))).toBeNull();
|
||||
expect(hiveConflictOf(new Error("x"))).toBeNull();
|
||||
expect(hiveErrorCode(new ApiError(404, "nf", { code: "HIVE_NOT_FOUND" }))).toBe("HIVE_NOT_FOUND");
|
||||
expect(hiveErrorCode(new ApiError(500, "x"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ApiError, apiFetch } from "@/lib/auth/api-client";
|
||||
import { idempotencyHeaders } from "@/lib/offline/idempotency";
|
||||
import type {
|
||||
CreateHiveInput,
|
||||
HiveAtView,
|
||||
HiveConflictBody,
|
||||
HiveHistoryPage,
|
||||
HiveSetupView,
|
||||
HiveState,
|
||||
HiveSystemDto,
|
||||
PostHiveChangeInput,
|
||||
} from "./types";
|
||||
|
||||
export const hiveKeys = {
|
||||
systems: ["hive-systems"] as const,
|
||||
detail: (colonyId: string) => ["colonies", colonyId, "hive"] as const,
|
||||
history: (colonyId: string) => ["colonies", colonyId, "hive", "history"] as const,
|
||||
};
|
||||
|
||||
const colonyPath = (colonyId: string) => `/colonies/${encodeURIComponent(colonyId)}/hive`;
|
||||
|
||||
export function fetchHiveSystems(): Promise<HiveSystemDto[]> {
|
||||
return apiFetch<HiveSystemDto[]>("/hive-systems");
|
||||
}
|
||||
|
||||
/** 404 HIVE_NOT_FOUND when the colony has no setup yet. */
|
||||
export function fetchHive(colonyId: string): Promise<HiveSetupView> {
|
||||
return apiFetch<HiveSetupView>(colonyPath(colonyId));
|
||||
}
|
||||
|
||||
/** Template of a system with fresh ids; 409 TEMPLATE_NOT_SET while it is empty. */
|
||||
export function fetchHiveTemplate(systemId: string): Promise<HiveState> {
|
||||
return apiFetch<HiveState>(`/hive-systems/${encodeURIComponent(systemId)}/template`);
|
||||
}
|
||||
|
||||
export function createHive(
|
||||
colonyId: string,
|
||||
input: CreateHiveInput,
|
||||
idempotencyKey: string,
|
||||
): Promise<HiveSetupView> {
|
||||
return apiFetch<HiveSetupView>(colonyPath(colonyId), {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders({ idempotencyKey }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** 409 HIVE_CONFLICT carries the current state; see `hiveConflictOf`. */
|
||||
export function postHiveChange(
|
||||
colonyId: string,
|
||||
input: PostHiveChangeInput,
|
||||
idempotencyKey: string,
|
||||
): Promise<HiveSetupView> {
|
||||
return apiFetch<HiveSetupView>(`${colonyPath(colonyId)}/changes`, {
|
||||
method: "POST",
|
||||
headers: idempotencyHeaders({ idempotencyKey }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchHiveHistory(colonyId: string, cursor?: string): Promise<HiveHistoryPage> {
|
||||
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
|
||||
return apiFetch<HiveHistoryPage>(`${colonyPath(colonyId)}/changes${query}`);
|
||||
}
|
||||
|
||||
export function fetchHiveAt(colonyId: string, day: string): Promise<HiveAtView> {
|
||||
return apiFetch<HiveAtView>(`${colonyPath(colonyId)}/at?day=${encodeURIComponent(day)}`);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
/** The HIVE_CONFLICT body of a 409 answer, or null for any other error. */
|
||||
export function hiveConflictOf(error: unknown): HiveConflictBody | null {
|
||||
if (!(error instanceof ApiError) || error.status !== 409) return null;
|
||||
const body = error.body;
|
||||
if (!isRecord(body) || body.code !== "HIVE_CONFLICT") return null;
|
||||
return body as unknown as HiveConflictBody;
|
||||
}
|
||||
|
||||
/** Machine code of an API error (HIVE_NOT_FOUND, TEMPLATE_NOT_SET, HIVE_EXISTS ...), if any. */
|
||||
export function hiveErrorCode(error: unknown): string | null {
|
||||
if (!(error instanceof ApiError) || !isRecord(error.body)) return null;
|
||||
return typeof error.body.code === "string" ? error.body.code : null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import {
|
||||
createHivePartType,
|
||||
createHiveSystem,
|
||||
updateHivePartType,
|
||||
updateHiveSystem,
|
||||
} from "./catalog-api";
|
||||
|
||||
vi.mock("@/lib/auth/api-client", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("@/lib/auth/api-client")>();
|
||||
return { ...original, apiFetch: vi.fn() };
|
||||
});
|
||||
|
||||
describe("hive catalog api", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("creates a system with POST", async () => {
|
||||
const input = { name: "Dadant", frameWidthMm: 435, frameHeightMm: 300, footprintLMm: 500, footprintWMm: 480 };
|
||||
await createHiveSystem(input);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/hive-systems", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
});
|
||||
|
||||
it("patches a system by encoded id", async () => {
|
||||
await updateHiveSystem("s 1", { active: false });
|
||||
expect(apiFetch).toHaveBeenCalledWith("/hive-systems/s%201", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ active: false }),
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a part type below its system", async () => {
|
||||
const input = { kind: "BOX" as const, name: "Zarge", lengthMm: 500, widthMm: 480, heightMm: 240, frameSlots: 10 };
|
||||
await createHivePartType("s1", input);
|
||||
expect(apiFetch).toHaveBeenCalledWith("/hive-systems/s1/part-types", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
});
|
||||
|
||||
it("patches a part type", async () => {
|
||||
await updateHivePartType("p 1", { active: false });
|
||||
expect(apiFetch).toHaveBeenCalledWith("/hive-part-types/p%201", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ active: false }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { apiFetch } from "@/lib/auth/api-client";
|
||||
import type { HiveBoxDepth, HivePartKind, HivePartTypeDto, HiveSystemDto, StateField } from "./types";
|
||||
|
||||
// Admin writes of the hive catalog (hive-systems:manage). Reads stay in api.ts
|
||||
// (`fetchHiveSystems`); callers refresh them via `hiveKeys.systems`.
|
||||
|
||||
export interface HiveSystemInput {
|
||||
name: string;
|
||||
frameWidthMm: number;
|
||||
frameHeightMm: number;
|
||||
footprintLMm: number;
|
||||
footprintWMm: number;
|
||||
}
|
||||
|
||||
/** Template editing is not part of this screen (it is saved from the editor). */
|
||||
export type HiveSystemPatch = Partial<HiveSystemInput> & { active?: boolean };
|
||||
|
||||
export interface HivePartTypeInput {
|
||||
kind: HivePartKind;
|
||||
name: string;
|
||||
articleRef?: string;
|
||||
lengthMm: number;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
boxDepth?: HiveBoxDepth;
|
||||
frameSlots?: number;
|
||||
stateSchema?: StateField[];
|
||||
}
|
||||
|
||||
/** The kind cannot change after creation; a schema may only be appended to. */
|
||||
export type HivePartTypePatch = Partial<Omit<HivePartTypeInput, "kind" | "articleRef">> & {
|
||||
/** null clears the article reference. */
|
||||
articleRef?: string | null;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const systemPath = (id: string) => `/hive-systems/${encodeURIComponent(id)}`;
|
||||
|
||||
export function createHiveSystem(input: HiveSystemInput): Promise<HiveSystemDto> {
|
||||
return apiFetch<HiveSystemDto>("/hive-systems", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateHiveSystem(id: string, patch: HiveSystemPatch): Promise<HiveSystemDto> {
|
||||
return apiFetch<HiveSystemDto>(systemPath(id), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export function createHivePartType(
|
||||
systemId: string,
|
||||
input: HivePartTypeInput,
|
||||
): Promise<HivePartTypeDto> {
|
||||
return apiFetch<HivePartTypeDto>(`${systemPath(systemId)}/part-types`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateHivePartType(id: string, patch: HivePartTypePatch): Promise<HivePartTypeDto> {
|
||||
return apiFetch<HivePartTypeDto>(`/hive-part-types/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import {
|
||||
describeStateFields,
|
||||
draftProblem,
|
||||
emptyDraft,
|
||||
fieldFromDraft,
|
||||
serverMessage,
|
||||
templatePartCount,
|
||||
type FieldDraft,
|
||||
} from "./catalog-form";
|
||||
import type { StateField } from "./types";
|
||||
|
||||
const level = (overrides: Partial<FieldDraft> = {}): FieldDraft => ({
|
||||
...emptyDraft(),
|
||||
kind: "level",
|
||||
key: "syrup",
|
||||
label: "Flüssigfutter",
|
||||
unit: "l",
|
||||
max: "3",
|
||||
step: "0.5",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const choice = (overrides: Partial<FieldDraft> = {}): FieldDraft => ({
|
||||
...emptyDraft(),
|
||||
kind: "choice",
|
||||
key: "lid",
|
||||
label: "Deckel",
|
||||
options: [
|
||||
{ value: "open", label: "offen" },
|
||||
{ value: "closed", label: "zu" },
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("describeStateFields", () => {
|
||||
it("writes level fields as range with unit and choices with their labels", () => {
|
||||
const schema: StateField[] = [
|
||||
{ key: "a", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5 },
|
||||
{ key: "b", label: "Futterteig", kind: "level", unit: "kg", max: 2.5, step: 0.5 },
|
||||
{ key: "c", label: "Deckel", kind: "choice", options: ["o", "z"], optionLabels: ["offen", "zu"] },
|
||||
];
|
||||
expect(describeStateFields(schema)).toBe("Flüssigfutter 0–3 l, Futterteig 0–2,5 kg, Deckel (offen / zu)");
|
||||
});
|
||||
|
||||
it("says so when there are no fields", () => {
|
||||
expect(describeStateFields([])).toBe("keine");
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftProblem", () => {
|
||||
it("accepts valid level and choice drafts", () => {
|
||||
expect(draftProblem(level(), [])).toBeNull();
|
||||
expect(draftProblem(choice(), [])).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["empty key", level({ key: "" })],
|
||||
["key over 40", level({ key: "k".repeat(41) })],
|
||||
["empty label", level({ label: " " })],
|
||||
["label over 60", level({ label: "l".repeat(61) })],
|
||||
["unit over 10", level({ unit: "x".repeat(11) })],
|
||||
["max zero", level({ max: "0" })],
|
||||
["max empty", level({ max: "" })],
|
||||
["step zero", level({ step: "0" })],
|
||||
["taken key", level({ key: "taken" })],
|
||||
["no options", choice({ options: [] })],
|
||||
["duplicate options", choice({ options: [{ value: "a", label: "A" }, { value: "a", label: "B" }] })],
|
||||
["option over 40", choice({ options: [{ value: "a".repeat(41), label: "A" }] })],
|
||||
["option label empty", choice({ options: [{ value: "a", label: "" }] })],
|
||||
])("rejects %s", (_name, draft) => {
|
||||
expect(draftProblem(draft, ["taken"])).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
describe("fieldFromDraft", () => {
|
||||
it("builds a level field and drops an empty feedType", () => {
|
||||
expect(fieldFromDraft(level())).toEqual({
|
||||
key: "syrup",
|
||||
label: "Flüssigfutter",
|
||||
kind: "level",
|
||||
unit: "l",
|
||||
max: 3,
|
||||
step: 0.5,
|
||||
});
|
||||
expect(fieldFromDraft(level({ feedType: "FEED_SYRUP" }))).toMatchObject({ feedType: "FEED_SYRUP" });
|
||||
});
|
||||
|
||||
it("builds a choice field with parallel options and labels", () => {
|
||||
expect(fieldFromDraft(choice())).toEqual({
|
||||
key: "lid",
|
||||
label: "Deckel",
|
||||
kind: "choice",
|
||||
options: ["open", "closed"],
|
||||
optionLabels: ["offen", "zu"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("serverMessage", () => {
|
||||
it("joins the messages list of a 400", () => {
|
||||
const error = new ApiError(400, "x", { code: "SCHEMA_INVALID", messages: ["a", "b"] });
|
||||
expect(serverMessage(error, "fallback")).toBe("a b");
|
||||
});
|
||||
|
||||
it("explains a non additive schema in German", () => {
|
||||
const error = new ApiError(400, "x", { code: "SCHEMA_NOT_ADDITIVE", message: "english" });
|
||||
expect(serverMessage(error, "fallback")).toMatch(/Zustandsfelder/);
|
||||
});
|
||||
|
||||
it("uses the message, then the fallback", () => {
|
||||
expect(serverMessage(new ApiError(409, "Name vergeben", {}), "fallback")).toBe("Name vergeben");
|
||||
expect(serverMessage(new Error(""), "fallback")).toBe("fallback");
|
||||
});
|
||||
});
|
||||
|
||||
describe("templatePartCount", () => {
|
||||
it("counts the stack of a template and treats everything else as none", () => {
|
||||
expect(templatePartCount({ schemaVersion: 1, stack: [{}, {}, {}] })).toBe(3);
|
||||
expect(templatePartCount({ schemaVersion: 1, stack: [] })).toBeNull();
|
||||
expect(templatePartCount(null)).toBeNull();
|
||||
expect(templatePartCount({})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { ApiError } from "@/lib/auth/api-client";
|
||||
import type { FeedType } from "@/lib/feedings/types";
|
||||
import type { StateField } from "./types";
|
||||
|
||||
// Form helpers of the catalog admin screen. The limits mirror the backend
|
||||
// (hive-state-schema.ts and the part-type DTO); the server stays the authority.
|
||||
|
||||
export const MAX_DIMENSION_MM = 5000;
|
||||
/** Upper bound of `frameSlots` in the backend DTO. */
|
||||
export const MAX_FRAME_SLOTS = 20;
|
||||
export const MAX_KEY_LENGTH = 40;
|
||||
export const MAX_FIELD_LABEL_LENGTH = 60;
|
||||
export const MAX_UNIT_LENGTH = 10;
|
||||
export const MAX_OPTION_LENGTH = 40;
|
||||
|
||||
export interface OptionDraft {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** A state field as typed; numbers stay text until the form is saved. */
|
||||
export interface FieldDraft {
|
||||
kind: "level" | "choice";
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
max: string;
|
||||
step: string;
|
||||
feedType: FeedType | "";
|
||||
options: OptionDraft[];
|
||||
}
|
||||
|
||||
export function emptyDraft(): FieldDraft {
|
||||
return {
|
||||
kind: "level",
|
||||
key: "",
|
||||
label: "",
|
||||
unit: "",
|
||||
max: "",
|
||||
step: "",
|
||||
feedType: "",
|
||||
options: [{ value: "", label: "" }],
|
||||
};
|
||||
}
|
||||
|
||||
function isPositive(text: string): boolean {
|
||||
const value = Number(text);
|
||||
return text.trim() !== "" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function levelProblem(draft: FieldDraft): string | null {
|
||||
if (draft.unit.length > MAX_UNIT_LENGTH) {
|
||||
return `Die Einheit darf höchstens ${MAX_UNIT_LENGTH} Zeichen haben.`;
|
||||
}
|
||||
if (!isPositive(draft.max)) return "Der Höchstwert muss größer als 0 sein.";
|
||||
if (!isPositive(draft.step)) return "Die Schrittweite muss größer als 0 sein.";
|
||||
return null;
|
||||
}
|
||||
|
||||
function choiceProblem(draft: FieldDraft): string | null {
|
||||
const { options } = draft;
|
||||
if (options.length === 0) return "Eine Auswahl braucht mindestens eine Option.";
|
||||
if (options.some((option) => option.value.trim() === "" || option.label.trim() === "")) {
|
||||
return "Jede Option braucht einen Wert und eine Bezeichnung.";
|
||||
}
|
||||
if (new Set(options.map((option) => option.value)).size !== options.length) {
|
||||
return "Die Werte der Optionen müssen verschieden sein.";
|
||||
}
|
||||
if (options.some((o) => o.value.length > MAX_OPTION_LENGTH || o.label.length > MAX_OPTION_LENGTH)) {
|
||||
return `Werte und Bezeichnungen der Optionen dürfen höchstens ${MAX_OPTION_LENGTH} Zeichen haben.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** First problem of a new field, or null. `takenKeys` are the keys already in the schema. */
|
||||
export function draftProblem(draft: FieldDraft, takenKeys: readonly string[]): string | null {
|
||||
if (draft.key.trim() === "" || draft.key.length > MAX_KEY_LENGTH) {
|
||||
return `Der Schlüssel braucht 1 bis ${MAX_KEY_LENGTH} Zeichen.`;
|
||||
}
|
||||
if (takenKeys.includes(draft.key)) return `Der Schlüssel „${draft.key}“ ist schon vergeben.`;
|
||||
if (draft.label.trim() === "" || draft.label.length > MAX_FIELD_LABEL_LENGTH) {
|
||||
return `Der Feldname braucht 1 bis ${MAX_FIELD_LABEL_LENGTH} Zeichen.`;
|
||||
}
|
||||
return draft.kind === "level" ? levelProblem(draft) : choiceProblem(draft);
|
||||
}
|
||||
|
||||
/** Call only for a draft that passed `draftProblem`. */
|
||||
export function fieldFromDraft(draft: FieldDraft): StateField {
|
||||
if (draft.kind === "choice") {
|
||||
return {
|
||||
key: draft.key,
|
||||
label: draft.label,
|
||||
kind: "choice",
|
||||
options: draft.options.map((option) => option.value),
|
||||
optionLabels: draft.options.map((option) => option.label),
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: draft.key,
|
||||
label: draft.label,
|
||||
kind: "level",
|
||||
unit: draft.unit,
|
||||
max: Number(draft.max),
|
||||
step: Number(draft.step),
|
||||
...(draft.feedType === "" ? {} : { feedType: draft.feedType }),
|
||||
};
|
||||
}
|
||||
|
||||
function germanNumber(value: number): string {
|
||||
return String(value).replace(".", ",");
|
||||
}
|
||||
|
||||
/** Short text such as „Flüssigfutter 0–3 l, Futterteig 0–2,5 kg“. */
|
||||
export function describeStateFields(schema: readonly StateField[]): string {
|
||||
if (schema.length === 0) return "keine";
|
||||
return schema
|
||||
.map((field) => {
|
||||
if (field.kind === "choice") return `${field.label} (${field.optionLabels.join(" / ")})`;
|
||||
const unit = field.unit === "" ? "" : ` ${field.unit}`;
|
||||
return `${field.label} 0–${germanNumber(field.max)}${unit}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
const SERVER_MESSAGES: Record<string, string> = {
|
||||
SCHEMA_NOT_ADDITIVE:
|
||||
"Bestehende Zustandsfelder bleiben unverändert, es können nur neue hinzukommen.",
|
||||
};
|
||||
|
||||
function bodyOf(error: unknown): Record<string, unknown> {
|
||||
if (!(error instanceof ApiError)) return {};
|
||||
return typeof error.body === "object" && error.body !== null
|
||||
? (error.body as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/** What the server said about a failed save, in one text; `fallback` when it said nothing. */
|
||||
export function serverMessage(error: unknown, fallback: string): string {
|
||||
const body = bodyOf(error);
|
||||
if (typeof body.code === "string" && SERVER_MESSAGES[body.code]) {
|
||||
return SERVER_MESSAGES[body.code];
|
||||
}
|
||||
if (Array.isArray(body.messages) && body.messages.length > 0) return body.messages.join(" ");
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Parts in the stored template, or null when the system has none yet. */
|
||||
export function templatePartCount(template: unknown): number | null {
|
||||
if (typeof template !== "object" || template === null) return null;
|
||||
const stack = (template as { stack?: unknown }).stack;
|
||||
return Array.isArray(stack) && stack.length > 0 ? stack.length : null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { copyHiveFrom } from "./copy";
|
||||
import { frames, inlineCatalog, part } from "./test-utils";
|
||||
import type { HiveState } from "./types";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
const source: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { label: "Mein Boden", inserts: [part("fi", "floor-insert")] }),
|
||||
part("b", "box", {
|
||||
label: "Brutraum",
|
||||
frames: [
|
||||
{ slot: 1, type: "BROOD", name: "Königin" },
|
||||
{ slot: 2, type: "HONEY" },
|
||||
{ slot: 3, type: "FOUNDATION" },
|
||||
...frames(7, "NONE").map((frame) => ({ ...frame, slot: frame.slot + 3 })),
|
||||
],
|
||||
}),
|
||||
part("fe", "feeder", { state: { syrupLiters: 2, fondantKg: 1 } }),
|
||||
part("l", "inner-lid", { state: { feedHole: "CLOSED" } }),
|
||||
],
|
||||
};
|
||||
|
||||
describe("copyHiveFrom", () => {
|
||||
const copy = copyHiveFrom(source, catalog);
|
||||
|
||||
it("resets levels to 0 and choices to the first option", () => {
|
||||
expect(copy.stack[2].state).toEqual({ syrupLiters: 0, fondantKg: 0 });
|
||||
expect(copy.stack[3].state).toEqual({ feedHole: "OPEN" });
|
||||
});
|
||||
|
||||
it("removes part labels and frame names", () => {
|
||||
expect(copy.stack[0].label).toBeUndefined();
|
||||
expect(copy.stack[1].label).toBeUndefined();
|
||||
expect(copy.stack[1].frames?.every((frame) => frame.name === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("turns every frame into an empty comb except foundations", () => {
|
||||
const types = copy.stack[1].frames?.map((frame) => frame.type);
|
||||
expect(types?.slice(0, 3)).toEqual(["EMPTY_COMB", "EMPTY_COMB", "FOUNDATION"]);
|
||||
expect(types?.slice(3).every((type) => type === "EMPTY_COMB")).toBe(true);
|
||||
expect(copy.stack[1].frames?.map((frame) => frame.slot)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it("gives every part and insert a fresh id and keeps the stack order", () => {
|
||||
const oldIds = ["f", "fi", "b", "fe", "l"];
|
||||
const newIds = [copy.stack[0].id, copy.stack[0].inserts?.[0].id, copy.stack[1].id, copy.stack[2].id, copy.stack[3].id];
|
||||
expect(new Set(newIds).size).toBe(5);
|
||||
expect(newIds.some((id) => oldIds.includes(String(id)))).toBe(false);
|
||||
expect(copy.stack.map((p) => p.partTypeId)).toEqual(["floor", "box", "feeder", "inner-lid"]);
|
||||
expect(copy.stack[0].inserts?.[0].partTypeId).toBe("floor-insert");
|
||||
});
|
||||
|
||||
it("does not change the source", () => {
|
||||
expect(source.stack[1].frames?.[0]).toEqual({ slot: 1, type: "BROOD", name: "Königin" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FrameType, HiveCatalog, HivePart, HiveState } from "./types";
|
||||
|
||||
// Spec 4.4: „Wie anderes Volk“ copies the stack with state values at their
|
||||
// start (0 / first option), no names, and all frames as empty combs except
|
||||
// foundations. Every part gets a fresh id.
|
||||
|
||||
const KEPT_FRAME_TYPE: FrameType = "FOUNDATION";
|
||||
const EMPTY_FRAME_TYPE: FrameType = "EMPTY_COMB";
|
||||
|
||||
function startState(part: HivePart, catalog: HiveCatalog): HivePart["state"] {
|
||||
const schema = catalog.partTypes.get(part.partTypeId)?.stateSchema ?? [];
|
||||
return Object.fromEntries(
|
||||
schema.map((field) => [field.key, field.kind === "choice" ? (field.options[0] ?? "") : 0]),
|
||||
);
|
||||
}
|
||||
|
||||
function copyPart(part: HivePart, catalog: HiveCatalog): HivePart {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
partTypeId: part.partTypeId,
|
||||
state: startState(part, catalog),
|
||||
...(part.inserts ? { inserts: part.inserts.map((insert) => copyPart(insert, catalog)) } : {}),
|
||||
...(part.frames
|
||||
? {
|
||||
frames: part.frames.map((frame) => ({
|
||||
slot: frame.slot,
|
||||
type: frame.type === KEPT_FRAME_TYPE ? KEPT_FRAME_TYPE : EMPTY_FRAME_TYPE,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function copyHiveFrom(source: HiveState, catalog: HiveCatalog): HiveState {
|
||||
return { schemaVersion: 1, stack: source.stack.map((part) => copyPart(part, catalog)) };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeHiveOps } from "./describe";
|
||||
import { inlineCatalog, part, templateState } from "./test-utils";
|
||||
import type { HiveOp, HiveState } from "./types";
|
||||
|
||||
describe("describeHiveOps", () => {
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
function stateWithInsert(): HiveState {
|
||||
const state = templateState();
|
||||
state.stack[0].inserts = [part("fi", "floor-insert")];
|
||||
return state;
|
||||
}
|
||||
|
||||
it("describes removed inserts and added parts", () => {
|
||||
const ops: HiveOp[] = [
|
||||
{ op: "removePart", id: "fi" },
|
||||
{ op: "addPart", part: part("b2", "box"), at: 2 },
|
||||
];
|
||||
expect(describeHiveOps(ops, stateWithInsert(), catalog)).toBe("Bausperre entfernt, Zarge hinzugefügt");
|
||||
});
|
||||
|
||||
it("uses the label of a part when it has one", () => {
|
||||
const state = templateState();
|
||||
state.stack[1].label = "Honigraum 1";
|
||||
expect(describeHiveOps([{ op: "removePart", id: "b" }], state, catalog)).toBe("Honigraum 1 entfernt");
|
||||
expect(describeHiveOps([{ op: "movePart", id: "b", to: 2 }], state, catalog)).toBe("Honigraum 1 verschoben");
|
||||
});
|
||||
|
||||
it("describes insert ops", () => {
|
||||
expect(
|
||||
describeHiveOps([{ op: "addInsert", hostId: "f", part: part("fi", "floor-insert") }], templateState(), catalog),
|
||||
).toBe("Bausperre eingelegt");
|
||||
expect(
|
||||
describeHiveOps([{ op: "addInsert", hostId: "b", part: part("e", "evaporator") }], templateState(), catalog),
|
||||
).toBe("Verdunster eingesetzt");
|
||||
});
|
||||
|
||||
it("describes setState with unit and option labels", () => {
|
||||
const state = templateState();
|
||||
state.stack.splice(2, 0, part("fe", "feeder"));
|
||||
expect(describeHiveOps([{ op: "setState", id: "fe", key: "syrupLiters", value: 2.5 }], state, catalog)).toBe(
|
||||
"Flüssigfutter 2,5 l",
|
||||
);
|
||||
expect(describeHiveOps([{ op: "setState", id: "l", key: "feedHole", value: "OPEN" }], state, catalog)).toBe(
|
||||
"Futterloch offen",
|
||||
);
|
||||
});
|
||||
|
||||
it("describes setFrame, setFrames and setLabel", () => {
|
||||
const state = templateState();
|
||||
expect(describeHiveOps([{ op: "setFrame", boxId: "b", slot: 4, type: "BROOD" }], state, catalog)).toBe(
|
||||
"Rähmchen 4 in Zarge: Brutwabe",
|
||||
);
|
||||
expect(describeHiveOps([{ op: "setFrames", boxId: "b", frames: [] }], state, catalog)).toBe(
|
||||
"Rähmchen in Zarge neu gesetzt",
|
||||
);
|
||||
expect(describeHiveOps([{ op: "setLabel", id: "b", label: "Brutraum" }], state, catalog)).toBe(
|
||||
"Zarge heißt jetzt „Brutraum“",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves parts added earlier in the same batch", () => {
|
||||
const ops: HiveOp[] = [
|
||||
{ op: "addPart", part: { ...part("n", "box"), label: "Aufsatz" }, at: 2 },
|
||||
{ op: "setFrame", boxId: "n", slot: 1, type: "HONEY" },
|
||||
];
|
||||
expect(describeHiveOps(ops, templateState(), catalog)).toBe(
|
||||
"Aufsatz hinzugefügt, Rähmchen 1 in Aufsatz: Honigwabe",
|
||||
);
|
||||
});
|
||||
|
||||
it("shortens more than three ops", () => {
|
||||
const ops: HiveOp[] = [1, 2, 3, 4, 5].map((slot) => ({ op: "setFrame", boxId: "b", slot, type: "BROOD" }));
|
||||
expect(describeHiveOps(ops, templateState(), catalog)).toBe(
|
||||
"Rähmchen 1 in Zarge: Brutwabe, Rähmchen 2 in Zarge: Brutwabe, Rähmchen 3 in Zarge: Brutwabe und 2 weitere",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { FRAME_TYPE_LABELS, PART_KIND_LABELS } from "./labels";
|
||||
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, StateField } from "./types";
|
||||
|
||||
const MAX_SHOWN_OPS = 3;
|
||||
|
||||
interface Named {
|
||||
label?: string;
|
||||
type?: CatalogPartType;
|
||||
}
|
||||
|
||||
function collect(part: HivePart, catalog: HiveCatalog, into: Map<string, Named>): void {
|
||||
into.set(part.id, { label: part.label, type: catalog.partTypes.get(part.partTypeId) });
|
||||
for (const insert of part.inserts ?? []) collect(insert, catalog, into);
|
||||
}
|
||||
|
||||
function nameOf(entry: Named | undefined): string {
|
||||
if (entry?.label) return entry.label;
|
||||
return entry?.type ? PART_KIND_LABELS[entry.type.kind] : "Teil";
|
||||
}
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
|
||||
|
||||
function valueText(field: StateField | undefined, value: number | string): string {
|
||||
if (!field) return String(value);
|
||||
if (field.kind === "choice") {
|
||||
const index = field.options.indexOf(String(value));
|
||||
return index >= 0 ? field.optionLabels[index] : String(value);
|
||||
}
|
||||
const text = numberFormat.format(Number(value));
|
||||
return field.unit ? `${text} ${field.unit}` : text;
|
||||
}
|
||||
|
||||
function describeOne(op: HiveOp, known: Map<string, Named>, catalog: HiveCatalog): string {
|
||||
switch (op.op) {
|
||||
case "addPart": {
|
||||
collect(op.part, catalog, known);
|
||||
return `${nameOf(known.get(op.part.id))} hinzugefügt`;
|
||||
}
|
||||
case "addInsert": {
|
||||
collect(op.part, catalog, known);
|
||||
const name = nameOf(known.get(op.part.id));
|
||||
return known.get(op.part.id)?.type?.kind === "FLOOR_INSERT" ? "Bausperre eingelegt" : `${name} eingesetzt`;
|
||||
}
|
||||
case "removePart":
|
||||
return `${nameOf(known.get(op.id))} entfernt`;
|
||||
case "movePart":
|
||||
return `${nameOf(known.get(op.id))} verschoben`;
|
||||
case "setState": {
|
||||
const entry = known.get(op.id);
|
||||
const field = entry?.type?.stateSchema.find((f) => f.key === op.key);
|
||||
return `${field?.label ?? op.key} ${valueText(field, op.value)}`;
|
||||
}
|
||||
case "setFrame":
|
||||
return `Rähmchen ${op.slot} in ${nameOf(known.get(op.boxId))}: ${FRAME_TYPE_LABELS[op.type]}`;
|
||||
case "setFrames":
|
||||
return `Rähmchen in ${nameOf(known.get(op.boxId))} neu gesetzt`;
|
||||
case "setLabel": {
|
||||
const name = nameOf(known.get(op.id));
|
||||
const entry = known.get(op.id);
|
||||
if (entry) entry.label = op.label ?? undefined;
|
||||
return op.label === null ? `Name von ${name} entfernt` : `${name} heißt jetzt „${op.label}“`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* German one-line summary of a change, e.g. „Bausperre entfernt, Zarge hinzugefügt“.
|
||||
* Names come from `before` plus parts the same batch adds; more than three ops
|
||||
* are shortened to the first three and „und n weitere“.
|
||||
*/
|
||||
export function describeHiveOps(ops: HiveOp[], before: HiveState, catalog: HiveCatalog): string {
|
||||
const known = new Map<string, Named>();
|
||||
for (const part of before.stack) collect(part, catalog, known);
|
||||
const texts = ops.map((op) => describeOne(op, known, catalog));
|
||||
if (texts.length <= MAX_SHOWN_OPS) return texts.join(", ");
|
||||
const rest = texts.length - MAX_SHOWN_OPS;
|
||||
return `${texts.slice(0, MAX_SHOWN_OPS).join(", ")} und ${rest} weitere`;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
CatalogPartType,
|
||||
HiveCatalog,
|
||||
HiveFacts,
|
||||
HiveLevelFact,
|
||||
HivePart,
|
||||
HiveState,
|
||||
HiveSystemDto,
|
||||
} from "./types";
|
||||
|
||||
const UNBUILT_FRAME_TYPES = new Set(["NONE", "FOUNDATION", "DIVIDER"]);
|
||||
|
||||
/** Catalog of a system as the API returns it (all part types, also inactive). */
|
||||
export function catalogFromSystem(system: HiveSystemDto): HiveCatalog {
|
||||
const rows: CatalogPartType[] = system.partTypes.map((row) => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
name: row.name,
|
||||
lengthMm: row.lengthMm,
|
||||
widthMm: row.widthMm,
|
||||
heightMm: row.heightMm,
|
||||
frameSlots: row.frameSlots,
|
||||
stateSchema: row.stateSchema,
|
||||
active: row.active,
|
||||
}));
|
||||
return { systemId: system.id, partTypes: new Map(rows.map((row) => [row.id, row])) };
|
||||
}
|
||||
|
||||
function allParts(state: HiveState): HivePart[] {
|
||||
return state.stack.flatMap((part) => [part, ...(part.inserts ?? [])]);
|
||||
}
|
||||
|
||||
function levelsOf(part: HivePart, catalog: HiveCatalog): HiveLevelFact[] {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
if (!type) return [];
|
||||
return type.stateSchema.flatMap((field) => {
|
||||
if (field.kind !== "level") return [];
|
||||
const raw = part.state[field.key];
|
||||
return [
|
||||
{
|
||||
partId: part.id,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
unit: field.unit,
|
||||
value: typeof raw === "number" ? raw : 0,
|
||||
max: field.max,
|
||||
...(field.feedType ? { feedType: field.feedType } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Pure derivation of display facts (spec 5.3), same as the backend. */
|
||||
export function deriveHiveFacts(state: HiveState, catalog: HiveCatalog): HiveFacts {
|
||||
const parts = allParts(state);
|
||||
const kindOf = (part: HivePart) => catalog.partTypes.get(part.partTypeId)?.kind;
|
||||
const frames = state.stack
|
||||
.filter((part) => kindOf(part) === "BOX")
|
||||
.flatMap((part) => part.frames ?? []);
|
||||
const evaporators = parts.filter((part) => kindOf(part) === "EVAPORATOR");
|
||||
const acid = evaporators.reduce((sum, part) => {
|
||||
const value = part.state.acidMl;
|
||||
return sum + (typeof value === "number" ? value : 0);
|
||||
}, 0);
|
||||
return {
|
||||
boxCount: state.stack.filter((part) => kindOf(part) === "BOX").length,
|
||||
broodFrames: frames.filter((f) => f.type === "BROOD").length,
|
||||
honeyFrames: frames.filter((f) => f.type === "HONEY").length,
|
||||
builtFrames: frames.filter((f) => !UNBUILT_FRAME_TYPES.has(f.type)).length,
|
||||
levels: parts.flatMap((part) => levelsOf(part, catalog)),
|
||||
hasFloorInsert: parts.some((part) => kindOf(part) === "FLOOR_INSERT"),
|
||||
evaporatorAcidMl: evaporators.length > 0 ? acid : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyHiveOps } from "./ops";
|
||||
import { hiveFollowUps } from "./follow-ups";
|
||||
import { inlineCatalog, part, templateState } from "./test-utils";
|
||||
import type { HiveOp, HiveState } from "./types";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
function withFeeder(): HiveState {
|
||||
const state = templateState();
|
||||
state.stack.splice(2, 0, part("fe", "feeder"));
|
||||
return state;
|
||||
}
|
||||
|
||||
function run(before: HiveState, ops: HiveOp[]) {
|
||||
const result = applyHiveOps(before, ops, catalog);
|
||||
if (!result.ok) throw new Error(result.reason);
|
||||
return hiveFollowUps(ops, before, result.state, catalog);
|
||||
}
|
||||
|
||||
describe("hiveFollowUps", () => {
|
||||
it("suggests a feeding when a feeder level rises", () => {
|
||||
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "syrupLiters", value: 3 }];
|
||||
expect(run(withFeeder(), ops)).toEqual([
|
||||
{ kind: "feeding", amount: 3, unit: "l", feedType: "SUGAR_WATER" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the increase and the feed type of the field", () => {
|
||||
const before = withFeeder();
|
||||
before.stack[2].state = { fondantKg: 0.5 };
|
||||
const ops: HiveOp[] = [{ op: "setState", id: "fe", key: "fondantKg", value: 2 }];
|
||||
expect(run(before, ops)).toEqual([{ kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" }]);
|
||||
});
|
||||
|
||||
it("suggests nothing when a level falls or stays", () => {
|
||||
const before = withFeeder();
|
||||
before.stack[2].state = { syrupLiters: 2 };
|
||||
expect(run(before, [{ op: "setState", id: "fe", key: "syrupLiters", value: 1 }])).toEqual([]);
|
||||
expect(run(before, [{ op: "setState", id: "fe", key: "syrupLiters", value: 2 }])).toEqual([]);
|
||||
});
|
||||
|
||||
it("nets several ops on the same level into one suggestion", () => {
|
||||
const ops: HiveOp[] = [
|
||||
{ op: "setState", id: "fe", key: "syrupLiters", value: 1 },
|
||||
{ op: "setState", id: "fe", key: "syrupLiters", value: 2 },
|
||||
];
|
||||
expect(run(withFeeder(), ops)).toEqual([{ kind: "feeding", amount: 2, unit: "l", feedType: "SUGAR_WATER" }]);
|
||||
});
|
||||
|
||||
it("suggests a treatment for an evaporator added with acid", () => {
|
||||
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
|
||||
const ops: HiveOp[] = [{ op: "addInsert", hostId: "b", part: evaporator }];
|
||||
expect(run(templateState(), ops)).toEqual([{ kind: "treatment", amountMl: 250 }]);
|
||||
});
|
||||
|
||||
it("suggests nothing for an evaporator added without acid", () => {
|
||||
const ops: HiveOp[] = [{ op: "addInsert", hostId: "b", part: part("ev", "evaporator") }];
|
||||
expect(run(templateState(), ops)).toEqual([]);
|
||||
});
|
||||
|
||||
it("suggests a treatment when the acid level rises", () => {
|
||||
const before = templateState();
|
||||
before.stack[1].inserts = [{ ...part("ev", "evaporator"), state: { acidMl: 100 } }];
|
||||
const ops: HiveOp[] = [{ op: "setState", id: "ev", key: "acidMl", value: 250 }];
|
||||
expect(run(before, ops)).toEqual([{ kind: "treatment", amountMl: 150 }]);
|
||||
});
|
||||
|
||||
it("suggests ending the treatment when an evaporator is removed", () => {
|
||||
const before = templateState();
|
||||
before.stack[1].inserts = [part("ev", "evaporator")];
|
||||
expect(run(before, [{ op: "removePart", id: "ev" }])).toEqual([{ kind: "endTreatment" }]);
|
||||
});
|
||||
|
||||
it("gives one treatment when an evaporator is added and raised in one batch", () => {
|
||||
const evaporator = { ...part("ev", "evaporator"), state: { acidMl: 250 } };
|
||||
const ops: HiveOp[] = [
|
||||
{ op: "addInsert", hostId: "b", part: evaporator },
|
||||
{ op: "setState", id: "ev", key: "acidMl", value: 300 },
|
||||
];
|
||||
expect(run(templateState(), ops)).toEqual([{ kind: "treatment", amountMl: 300 }]);
|
||||
});
|
||||
|
||||
it("gives one treatment when an evaporator is added empty and then raised", () => {
|
||||
const ops: HiveOp[] = [
|
||||
{ op: "addInsert", hostId: "b", part: part("ev", "evaporator") },
|
||||
{ op: "setState", id: "ev", key: "acidMl", value: 250 },
|
||||
];
|
||||
expect(run(templateState(), ops)).toEqual([{ kind: "treatment", amountMl: 250 }]);
|
||||
});
|
||||
|
||||
it("gives nothing when a level is raised and lowered again in one batch", () => {
|
||||
const ops: HiveOp[] = [
|
||||
{ op: "setState", id: "fe", key: "syrupLiters", value: 3 },
|
||||
{ op: "setState", id: "fe", key: "syrupLiters", value: 0 },
|
||||
];
|
||||
expect(run(withFeeder(), ops)).toEqual([]);
|
||||
});
|
||||
|
||||
it("suggests the net filled amount for a feeder added and filled in the same batch", () => {
|
||||
const filled = { ...part("fe2", "feeder"), state: { syrupLiters: 2 } };
|
||||
const ops: HiveOp[] = [
|
||||
{ op: "addPart", part: filled, at: 2 },
|
||||
{ op: "setState", id: "fe2", key: "syrupLiters", value: 3 },
|
||||
];
|
||||
expect(run(templateState(), ops)).toEqual([
|
||||
{ kind: "feeding", amount: 3, unit: "l", feedType: "SUGAR_WATER" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("suggests a feeding for a feeder added already filled", () => {
|
||||
const filled = { ...part("fe2", "feeder"), state: { fondantKg: 1.5 } };
|
||||
expect(run(templateState(), [{ op: "addPart", part: filled, at: 2 }])).toEqual([
|
||||
{ kind: "feeding", amount: 1.5, unit: "kg", feedType: "FEED_PASTE" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FeedType } from "@/lib/feedings/types";
|
||||
import type { CatalogPartType, HiveCatalog, HiveOp, HivePart, HiveState, LevelField } from "./types";
|
||||
|
||||
// Spec section 8: suggestions only. Nothing here books a feeding or treatment.
|
||||
|
||||
export type FollowUp =
|
||||
| { kind: "feeding"; amount: number; unit: "l" | "kg"; feedType: FeedType }
|
||||
| { kind: "treatment"; amountMl: number }
|
||||
| { kind: "endTreatment" };
|
||||
|
||||
const FEED_TYPES: readonly FeedType[] = ["SUGAR_WATER", "FEED_SYRUP", "FEED_PASTE", "OWN_HONEY", "OTHER"];
|
||||
const ACID_KEY = "acidMl";
|
||||
const ROUNDING = 1000;
|
||||
|
||||
function findPart(state: HiveState, id: string): HivePart | undefined {
|
||||
for (const part of state.stack) {
|
||||
if (part.id === id) return part;
|
||||
const insert = part.inserts?.find((candidate) => candidate.id === id);
|
||||
if (insert) return insert;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function typeOf(part: HivePart | undefined, catalog: HiveCatalog): CatalogPartType | undefined {
|
||||
return part ? catalog.partTypes.get(part.partTypeId) : undefined;
|
||||
}
|
||||
|
||||
function levelOf(part: HivePart | undefined, key: string): number {
|
||||
const raw = part?.state[key];
|
||||
return typeof raw === "number" ? raw : 0;
|
||||
}
|
||||
|
||||
function increase(before: HivePart | undefined, after: HivePart, key: string): number {
|
||||
return Math.round((levelOf(after, key) - levelOf(before, key)) * ROUNDING) / ROUNDING;
|
||||
}
|
||||
|
||||
function feedingFor(
|
||||
field: LevelField,
|
||||
amount: number,
|
||||
): FollowUp | null {
|
||||
const feedType = FEED_TYPES.find((candidate) => candidate === field.feedType);
|
||||
if (!feedType || (field.unit !== "l" && field.unit !== "kg")) return null;
|
||||
return { kind: "feeding", amount, unit: field.unit, feedType };
|
||||
}
|
||||
|
||||
function treatmentFor(before: HivePart | undefined, after: HivePart): FollowUp | null {
|
||||
// A part absent in `before` (added in this batch) counts as 0 before.
|
||||
const delta = increase(before, after, ACID_KEY);
|
||||
return delta > 0 ? { kind: "treatment", amountMl: delta } : null;
|
||||
}
|
||||
|
||||
function feedingForPart(
|
||||
before: HivePart | undefined,
|
||||
after: HivePart,
|
||||
key: string,
|
||||
catalog: HiveCatalog,
|
||||
): FollowUp | null {
|
||||
// A feeder added in this batch counts as 0 before: filling it is a feeding.
|
||||
const field = typeOf(after, catalog)?.stateSchema.find((f) => f.key === key);
|
||||
const delta = increase(before, after, key);
|
||||
return field?.kind === "level" && delta > 0 ? feedingFor(field, delta) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggestions after applied ops (spec section 8). Computed from the net
|
||||
* per-part, per-level difference between `before` and `after`, so several ops
|
||||
* on one level (also add-then-raise) give exactly one suggestion. A part added
|
||||
* in the batch counts as 0 before, for evaporators and feeders alike.
|
||||
*/
|
||||
export function hiveFollowUps(
|
||||
ops: HiveOp[],
|
||||
before: HiveState,
|
||||
after: HiveState,
|
||||
catalog: HiveCatalog,
|
||||
): FollowUp[] {
|
||||
const out: FollowUp[] = [];
|
||||
const seen = new Set<string>();
|
||||
const once = (id: string, key: string): boolean => {
|
||||
const dedupeKey = `${id}/${key}`;
|
||||
if (seen.has(dedupeKey)) return false;
|
||||
seen.add(dedupeKey);
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const op of ops) {
|
||||
const followUps: (FollowUp | null)[] = [];
|
||||
if (op.op === "setState" && once(op.id, op.key)) {
|
||||
const now = findPart(after, op.id);
|
||||
const was = findPart(before, op.id);
|
||||
const kind = typeOf(now, catalog)?.kind;
|
||||
if (now && kind === "EVAPORATOR" && op.key === ACID_KEY) followUps.push(treatmentFor(was, now));
|
||||
else if (now && kind === "FEEDER") followUps.push(feedingForPart(was, now, op.key, catalog));
|
||||
} else if (op.op === "addInsert" || op.op === "addPart") {
|
||||
const now = findPart(after, op.part.id);
|
||||
const type = typeOf(now, catalog);
|
||||
if (now && type?.kind === "EVAPORATOR" && once(now.id, ACID_KEY)) {
|
||||
followUps.push(treatmentFor(findPart(before, now.id), now));
|
||||
} else if (now && type?.kind === "FEEDER") {
|
||||
for (const field of type.stateSchema) {
|
||||
if (field.kind === "level" && once(now.id, field.key)) {
|
||||
followUps.push(feedingForPart(findPart(before, now.id), now, field.key, catalog));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (op.op === "removePart") {
|
||||
if (typeOf(findPart(before, op.id), catalog)?.kind === "EVAPORATOR") followUps.push({ kind: "endTreatment" });
|
||||
}
|
||||
for (const followUp of followUps) if (followUp) out.push(followUp);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { FrameType, HivePartKind } from "./types";
|
||||
|
||||
export const FRAME_TYPE_LABELS: Record<FrameType, string> = {
|
||||
BROOD: "Brutwabe",
|
||||
DRONE: "Drohnenwabe",
|
||||
HONEY: "Honigwabe",
|
||||
POLLEN: "Pollenwabe",
|
||||
FEED: "Futterwabe",
|
||||
FOUNDATION: "Mittelwand",
|
||||
EMPTY_COMB: "Leerwabe",
|
||||
DRONE_FRAME: "Baurahmen",
|
||||
DIVIDER: "Schied",
|
||||
NONE: "leer",
|
||||
};
|
||||
|
||||
/** Short generic names, used when a part has no label of its own. */
|
||||
export const PART_KIND_LABELS: Record<HivePartKind, string> = {
|
||||
FLOOR: "Boden",
|
||||
BOX: "Zarge",
|
||||
DIVIDER_BOARD: "Zwischenboden",
|
||||
QUEEN_EXCLUDER: "Absperrgitter",
|
||||
FEEDER: "Futterzarge",
|
||||
COVER_SHEET: "Abdeckfolie",
|
||||
PROPOLIS_SCREEN: "Propolisgitter",
|
||||
INNER_LID: "Holzdeckel",
|
||||
INSULATION: "Dämmung",
|
||||
ROOF: "Dach",
|
||||
FLOOR_INSERT: "Bausperre",
|
||||
EVAPORATOR: "Verdunster",
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hiveLayout } from "./layout";
|
||||
import { inlineCatalog, part, templateState } from "./test-utils";
|
||||
|
||||
describe("hiveLayout", () => {
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
it("computes the template height with the roof overlapping by 35 mm", () => {
|
||||
const layout = hiveLayout(templateState(), catalog);
|
||||
expect(layout.bounds.h).toBeCloseTo(4.31, 9);
|
||||
expect(layout.bounds.w).toBeCloseTo(5.25, 9);
|
||||
expect(layout.bounds.d).toBeCloseTo(4.3, 9);
|
||||
});
|
||||
|
||||
it("stacks parts on +y from 0 with centres and sizes in units of 100 mm", () => {
|
||||
const [floor, box, cover] = hiveLayout(templateState(), catalog).parts;
|
||||
expect(floor.center).toEqual([0, 0.6, 0]);
|
||||
expect(floor.size).toEqual([5.25, 1.2, 4.3]);
|
||||
expect(floor.stackIndex).toBe(0);
|
||||
expect(box.center[1]).toBeCloseTo(1.2 + 1.15, 9);
|
||||
expect(cover.center[1]).toBeCloseTo(3.5 + 0.005, 9);
|
||||
expect(box.stackIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("lets the roof overlap the part below while using its own height", () => {
|
||||
const layout = hiveLayout(templateState(), catalog);
|
||||
const roof = layout.parts[layout.parts.length - 1];
|
||||
const below = layout.parts[layout.parts.length - 2];
|
||||
const belowTop = below.center[1] + below.size[1] / 2;
|
||||
const roofBottom = roof.center[1] - roof.size[1] / 2;
|
||||
expect(belowTop - roofBottom).toBeCloseTo(0.35, 9);
|
||||
expect(roof.size[1]).toBeCloseTo(0.6, 9);
|
||||
});
|
||||
|
||||
it("places 10 frames evenly across the inner width of the box", () => {
|
||||
const layout = hiveLayout(templateState(), catalog);
|
||||
expect(layout.frames).toHaveLength(10);
|
||||
const inner = 5.25 - 0.4;
|
||||
const xs = layout.frames.map((f) => f.center[0]);
|
||||
expect(xs[0]).toBeCloseTo(-inner / 2 + inner / 20, 9);
|
||||
expect(xs[9]).toBeCloseTo(inner / 2 - inner / 20, 9);
|
||||
const pitch = inner / 10;
|
||||
xs.slice(1).forEach((x, i) => expect(x - xs[i]).toBeCloseTo(pitch, 9));
|
||||
layout.frames.forEach((f, i) => {
|
||||
expect(f.boxId).toBe("b");
|
||||
expect(f.slot).toBe(i + 1);
|
||||
expect(f.size[0]).toBeCloseTo(0.25, 9);
|
||||
expect(f.size[1]).toBeCloseTo(2.2, 9);
|
||||
});
|
||||
});
|
||||
|
||||
it("puts the floor insert 5 mm above the floor bottom and the evaporator on the box bottom", () => {
|
||||
const state = templateState();
|
||||
state.stack[0].inserts = [part("fi", "floor-insert")];
|
||||
state.stack[1].inserts = [part("ev", "evaporator")];
|
||||
const layout = hiveLayout(state, catalog);
|
||||
const insert = layout.inserts.find((i) => i.id === "fi")!;
|
||||
const evaporator = layout.inserts.find((i) => i.id === "ev")!;
|
||||
expect(insert.center[1]).toBeCloseTo(0.05, 9);
|
||||
expect(insert.kind).toBe("FLOOR_INSERT");
|
||||
expect(evaporator.center[1]).toBeCloseTo(1.2 + 0.6, 9);
|
||||
expect(evaporator.stackIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("returns an empty layout for an empty stack", () => {
|
||||
const layout = hiveLayout({ schemaVersion: 1, stack: [] }, catalog);
|
||||
expect(layout).toEqual({ parts: [], inserts: [], frames: [], bounds: { w: 0, h: 0, d: 0 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { CatalogPartType, FrameType, HiveCatalog, HivePart, HivePartKind, HiveState } from "./types";
|
||||
|
||||
// Layout math shared by the 3D scene, the SVG fallback and the tests.
|
||||
// 1 unit = 100 mm; x = length, y = height (stack grows on +y from 0), z = width.
|
||||
// The model is centred at x = z = 0.
|
||||
|
||||
export type Vec3 = [number, number, number];
|
||||
|
||||
export interface PartBox {
|
||||
id: string;
|
||||
partTypeId: string;
|
||||
kind: HivePartKind;
|
||||
center: Vec3;
|
||||
size: Vec3;
|
||||
/** Stack index; an insert carries the index of its host. */
|
||||
stackIndex: number;
|
||||
}
|
||||
|
||||
export interface FrameBox {
|
||||
boxId: string;
|
||||
slot: number;
|
||||
type: FrameType;
|
||||
name?: string;
|
||||
center: Vec3;
|
||||
size: Vec3;
|
||||
}
|
||||
|
||||
export interface HiveLayout {
|
||||
parts: PartBox[];
|
||||
inserts: PartBox[];
|
||||
frames: FrameBox[];
|
||||
bounds: { w: number; h: number; d: number };
|
||||
}
|
||||
|
||||
const UNIT_MM = 100;
|
||||
const ROOF_OVERLAP_MM = 35;
|
||||
const BOX_WALL_MM = 20;
|
||||
const FRAME_THICKNESS_MM = 25;
|
||||
const FRAME_HEIGHT_MARGIN_MM = 10;
|
||||
const FLOOR_INSERT_LIFT_MM = 5;
|
||||
|
||||
const mm = (value: number): number => value / UNIT_MM;
|
||||
|
||||
function sizeOf(type: CatalogPartType): Vec3 {
|
||||
return [mm(type.lengthMm), mm(type.heightMm), mm(type.widthMm)];
|
||||
}
|
||||
|
||||
function insertBox(
|
||||
insert: HivePart,
|
||||
type: CatalogPartType,
|
||||
host: PartBox,
|
||||
): PartBox {
|
||||
const size = sizeOf(type);
|
||||
const hostBottom = host.center[1] - host.size[1] / 2;
|
||||
// FLOOR_INSERT lies just above the floor's bottom area (its centre y = floor
|
||||
// bottom + 5 mm, y is always a centre); every other insert
|
||||
// (the EVAPORATOR) stands on the bottom of the box it sits in.
|
||||
const centerY =
|
||||
type.kind === "FLOOR_INSERT" ? hostBottom + mm(FLOOR_INSERT_LIFT_MM) : hostBottom + size[1] / 2;
|
||||
return {
|
||||
id: insert.id,
|
||||
partTypeId: insert.partTypeId,
|
||||
kind: type.kind,
|
||||
center: [0, centerY, 0],
|
||||
size,
|
||||
stackIndex: host.stackIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function frameBoxes(part: HivePart, type: CatalogPartType, box: PartBox): FrameBox[] {
|
||||
const frames = part.frames ?? [];
|
||||
if (frames.length === 0) return [];
|
||||
const inner = box.size[0] - 2 * mm(BOX_WALL_MM);
|
||||
const pitch = inner / frames.length;
|
||||
const size: Vec3 = [
|
||||
mm(FRAME_THICKNESS_MM),
|
||||
mm(type.heightMm - FRAME_HEIGHT_MARGIN_MM),
|
||||
box.size[2] - 2 * mm(BOX_WALL_MM),
|
||||
];
|
||||
return frames.map((frame, index) => ({
|
||||
boxId: part.id,
|
||||
slot: frame.slot,
|
||||
type: frame.type,
|
||||
...(frame.name !== undefined ? { name: frame.name } : {}),
|
||||
center: [-inner / 2 + pitch * (index + 0.5), box.center[1], 0],
|
||||
size,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Pure layout of a hive state in 3D units (1 = 100 mm). Unknown part types are skipped. */
|
||||
export function hiveLayout(state: HiveState, catalog: HiveCatalog): HiveLayout {
|
||||
const parts: PartBox[] = [];
|
||||
const inserts: PartBox[] = [];
|
||||
const frames: FrameBox[] = [];
|
||||
let top = 0;
|
||||
let maxTop = 0;
|
||||
let width = 0;
|
||||
let depth = 0;
|
||||
|
||||
state.stack.forEach((part, stackIndex) => {
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
if (!type) return;
|
||||
const size = sizeOf(type);
|
||||
const bottom = type.kind === "ROOF" ? top - mm(ROOF_OVERLAP_MM) : top;
|
||||
const box: PartBox = {
|
||||
id: part.id,
|
||||
partTypeId: part.partTypeId,
|
||||
kind: type.kind,
|
||||
center: [0, bottom + size[1] / 2, 0],
|
||||
size,
|
||||
stackIndex,
|
||||
};
|
||||
parts.push(box);
|
||||
top = bottom + size[1];
|
||||
maxTop = Math.max(maxTop, top);
|
||||
width = Math.max(width, size[0]);
|
||||
depth = Math.max(depth, size[2]);
|
||||
|
||||
for (const insert of part.inserts ?? []) {
|
||||
const insertType = catalog.partTypes.get(insert.partTypeId);
|
||||
if (insertType) inserts.push(insertBox(insert, insertType, box));
|
||||
}
|
||||
if (type.kind === "BOX") frames.push(...frameBoxes(part, type, box));
|
||||
});
|
||||
|
||||
return { parts, inserts, frames, bounds: { w: width, h: maxTop, d: depth } };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { partDisplayName } from "./names";
|
||||
import { frames, inlineCatalog, part, templateState } from "./test-utils";
|
||||
import type { HiveState } from "./types";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
describe("partDisplayName", () => {
|
||||
it("uses the kind label when a kind occurs once", () => {
|
||||
expect(partDisplayName(templateState(), catalog, "f")).toBe("Boden");
|
||||
expect(partDisplayName(templateState(), catalog, "b")).toBe("Zarge");
|
||||
});
|
||||
|
||||
it("numbers same-kind parts from bottom to top", () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [part("f", "floor"), part("b1", "box", { frames: frames(10) }), part("b2", "box", { frames: frames(10) })],
|
||||
};
|
||||
expect(partDisplayName(state, catalog, "b1")).toBe("Zarge 1");
|
||||
expect(partDisplayName(state, catalog, "b2")).toBe("Zarge 2");
|
||||
});
|
||||
|
||||
it("prefers a custom label and does not number it", () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [part("b1", "box", { label: "Brutraum" }), part("b2", "box")],
|
||||
};
|
||||
expect(partDisplayName(state, catalog, "b1")).toBe("Brutraum");
|
||||
expect(partDisplayName(state, catalog, "b2")).toBe("Zarge 2");
|
||||
});
|
||||
|
||||
it("finds inserts and falls back for unknown ids", () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [part("b1", "box", { inserts: [part("e1", "evaporator")] })],
|
||||
};
|
||||
expect(partDisplayName(state, catalog, "e1")).toBe("Verdunster");
|
||||
expect(partDisplayName(state, catalog, "nope")).toBe("Teil");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { PART_KIND_LABELS } from "./labels";
|
||||
import type { HiveCatalog, HivePart, HivePartKind, HiveState } from "./types";
|
||||
|
||||
const FALLBACK_NAME = "Teil";
|
||||
|
||||
interface Entry {
|
||||
part: HivePart;
|
||||
kind: HivePartKind | undefined;
|
||||
}
|
||||
|
||||
/** Every part of the stack and its inserts, bottom to top (inserts after their host). */
|
||||
function flatten(state: HiveState, catalog: HiveCatalog): Entry[] {
|
||||
const entries: Entry[] = [];
|
||||
const visit = (part: HivePart): void => {
|
||||
entries.push({ part, kind: catalog.partTypes.get(part.partTypeId)?.kind });
|
||||
for (const insert of part.inserts ?? []) visit(insert);
|
||||
};
|
||||
state.stack.forEach(visit);
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name shown in the UI: the part's own label, else the kind label, numbered
|
||||
* from bottom to top when the kind occurs more than once ("Zarge 1", "Zarge 2").
|
||||
* Shared by the outline, the 3D labels and the sheets.
|
||||
*/
|
||||
export function partDisplayName(state: HiveState, catalog: HiveCatalog, partId: string): string {
|
||||
const entries = flatten(state, catalog);
|
||||
const entry = entries.find((candidate) => candidate.part.id === partId);
|
||||
if (!entry) return FALLBACK_NAME;
|
||||
if (entry.part.label) return entry.part.label;
|
||||
if (!entry.kind) return FALLBACK_NAME;
|
||||
|
||||
const base = PART_KIND_LABELS[entry.kind];
|
||||
const sameKind = entries.filter((candidate) => candidate.kind === entry.kind);
|
||||
if (sameKind.length < 2) return base;
|
||||
return `${base} ${sameKind.indexOf(entry) + 1}`;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyHiveOps } from "./ops";
|
||||
import { deriveHiveFacts } from "./facts";
|
||||
import { validateHiveState } from "./validate";
|
||||
import {
|
||||
fixtureDirExists,
|
||||
inlineCatalog,
|
||||
loadFixtureCases,
|
||||
loadFixtureCatalog,
|
||||
part,
|
||||
templateState,
|
||||
} from "./test-utils";
|
||||
|
||||
function deepFreeze<T>(value: T): T {
|
||||
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
||||
Object.freeze(value);
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Runs the shared backend fixtures; skipped when the image build does not see
|
||||
// the backend folder (same pattern as lib/label-templates/placeholder-keys.spec.ts).
|
||||
const hasFixtures = fixtureDirExists();
|
||||
const cases = hasFixtures ? loadFixtureCases() : [];
|
||||
|
||||
describe("applyHiveOps backend fixtures", () => {
|
||||
it.skipIf(!hasFixtures)("finds at least 20 cases", () => {
|
||||
expect(cases.length).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it.skipIf(!hasFixtures).each(cases.map((c) => [c.file, c.data] as const))("%s", (_file, data) => {
|
||||
const catalog = loadFixtureCatalog(data.inactivePartTypeIds);
|
||||
const result = applyHiveOps(
|
||||
deepFreeze(structuredClone(data.state)),
|
||||
deepFreeze(structuredClone(data.ops)),
|
||||
catalog,
|
||||
);
|
||||
if (data.expect.ok) {
|
||||
expect(result).toEqual({ ok: true, state: data.expect.state });
|
||||
expect(validateHiveState(data.expect.state, catalog)).toEqual([]);
|
||||
} else {
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.opIndex).toBe(data.expect.opIndex);
|
||||
expect(result.reason.split(":")[0]).toBe(data.expect.reasonCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyHiveOps (inline catalog)", () => {
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
it("does not mutate a deep-frozen input", () => {
|
||||
const state = deepFreeze(templateState());
|
||||
const result = applyHiveOps(
|
||||
state,
|
||||
deepFreeze([{ op: "setLabel" as const, id: "b", label: "Brut" }]),
|
||||
catalog,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a frame name when setFrame sends no name and removes it for null", () => {
|
||||
const named = applyHiveOps(
|
||||
templateState(),
|
||||
[{ op: "setFrame", boxId: "b", slot: 2, type: "BROOD", name: "Königin" }],
|
||||
catalog,
|
||||
);
|
||||
if (!named.ok) throw new Error(named.reason);
|
||||
const kept = applyHiveOps(named.state, [{ op: "setFrame", boxId: "b", slot: 2, type: "HONEY" }], catalog);
|
||||
const removed = applyHiveOps(
|
||||
named.state,
|
||||
[{ op: "setFrame", boxId: "b", slot: 2, type: "HONEY", name: null }],
|
||||
catalog,
|
||||
);
|
||||
expect(kept.ok && kept.state.stack[1].frames?.[1]).toEqual({ slot: 2, type: "HONEY", name: "Königin" });
|
||||
expect(removed.ok && removed.state.stack[1].frames?.[1]).toEqual({ slot: 2, type: "HONEY" });
|
||||
});
|
||||
|
||||
it("never leaves an empty inserts array", () => {
|
||||
const added = applyHiveOps(
|
||||
templateState(),
|
||||
[{ op: "addInsert", hostId: "b", part: part("e", "evaporator") }],
|
||||
catalog,
|
||||
);
|
||||
if (!added.ok) throw new Error(added.reason);
|
||||
const removed = applyHiveOps(added.state, [{ op: "removePart", id: "e" }], catalog);
|
||||
expect(removed.ok && "inserts" in removed.state.stack[1]).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the failing op index and a reason code", () => {
|
||||
const result = applyHiveOps(
|
||||
templateState(),
|
||||
[{ op: "setLabel", id: "b", label: "ok" }, { op: "removePart", id: "f" }],
|
||||
catalog,
|
||||
);
|
||||
expect(result).toMatchObject({ ok: false, opIndex: 1 });
|
||||
expect(!result.ok && result.reason.startsWith("RULE_FLOOR_BOTTOM")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects more than 100 ops with LIMIT_OPS", () => {
|
||||
const ops = Array.from({ length: 101 }, () => ({ op: "setLabel" as const, id: "b", label: null }));
|
||||
const result = applyHiveOps(templateState(), ops, catalog);
|
||||
expect(!result.ok && result.reason.startsWith("LIMIT_OPS")).toBe(true);
|
||||
});
|
||||
|
||||
it("measures label length in UTF-16 code units", () => {
|
||||
const label = "😀".repeat(21); // 42 code units, 21 code points
|
||||
const result = applyHiveOps(templateState(), [{ op: "setLabel", id: "b", label }], catalog);
|
||||
expect(!result.ok && result.reason.startsWith("LIMIT_TEXT")).toBe(true);
|
||||
});
|
||||
|
||||
it("reports STATE_STEP beyond the 1e-9 tolerance and accepts inside it", () => {
|
||||
const base = applyHiveOps(
|
||||
templateState(),
|
||||
[{ op: "addPart", part: part("fe", "feeder"), at: 2 }],
|
||||
catalog,
|
||||
);
|
||||
if (!base.ok) throw new Error(base.reason);
|
||||
const inside = applyHiveOps(base.state, [{ op: "setState", id: "fe", key: "syrupLiters", value: 1 + 1e-10 }], catalog);
|
||||
const outside = applyHiveOps(base.state, [{ op: "setState", id: "fe", key: "syrupLiters", value: 1.0001 }], catalog);
|
||||
expect(inside.ok).toBe(true);
|
||||
expect(!outside.ok && outside.reason.startsWith("STATE_STEP")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveHiveFacts", () => {
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
it("lists every level field even at 0 and counts frames", () => {
|
||||
const state = templateState();
|
||||
state.stack.splice(2, 0, part("fe", "feeder"));
|
||||
state.stack[1].frames = state.stack[1].frames!.map((f, i) =>
|
||||
i < 3 ? { ...f, type: "BROOD" } : i < 5 ? { ...f, type: "HONEY" } : f,
|
||||
);
|
||||
const facts = deriveHiveFacts(state, catalog);
|
||||
expect(facts).toMatchObject({ boxCount: 1, broodFrames: 3, honeyFrames: 2, builtFrames: 5 });
|
||||
expect(facts.levels.map((l) => [l.key, l.value, l.feedType])).toEqual([
|
||||
["syrupLiters", 0, "SUGAR_WATER"],
|
||||
["fondantKg", 0, "FEED_PASTE"],
|
||||
]);
|
||||
expect(facts.hasFloorInsert).toBe(false);
|
||||
expect(facts.evaporatorAcidMl).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
MAX_OPS,
|
||||
type HiveApplyResult,
|
||||
type HiveCatalog,
|
||||
type HiveFrame,
|
||||
type HiveOp,
|
||||
type HivePart,
|
||||
type HiveState,
|
||||
} from "./types";
|
||||
import { validateHiveState } from "./validate";
|
||||
|
||||
// Port of ApiCore-Backend/src/hive/hive-ops.ts: same semantics, same reason
|
||||
// codes. Checked against the shared fixtures in ApiCore-Backend/test/fixtures.
|
||||
|
||||
interface Location {
|
||||
part: HivePart;
|
||||
list: HivePart[];
|
||||
index: number;
|
||||
isInsert: boolean;
|
||||
}
|
||||
|
||||
/** Result of one op on the working copy: a reason string, or null on success. */
|
||||
type OpOutcome = string | null;
|
||||
|
||||
function locate(state: HiveState, id: unknown): Location | null {
|
||||
for (let index = 0; index < state.stack.length; index++) {
|
||||
const part = state.stack[index];
|
||||
if (part.id === id) return { part, list: state.stack, index, isInsert: false };
|
||||
const inserts = part.inserts ?? [];
|
||||
for (let i = 0; i < inserts.length; i++) {
|
||||
if (inserts[i].id === id) return { part: inserts[i], list: inserts, index: i, isInsert: true };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function missing(id: unknown): string {
|
||||
return `MISSING_ID: no part with id ${String(id)}`;
|
||||
}
|
||||
|
||||
function clamp(value: unknown, max: number): number {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return max;
|
||||
return Math.min(Math.max(Math.trunc(value), 0), max);
|
||||
}
|
||||
|
||||
/** Catalog membership and active check for a part to be added (and its inserts). */
|
||||
function checkAddable(part: HivePart, catalog: HiveCatalog): OpOutcome {
|
||||
if (typeof part !== "object" || part === null) return "MISSING_ID: part is not an object";
|
||||
const type = catalog.partTypes.get(part.partTypeId);
|
||||
if (!type) return `FOREIGN_PART_TYPE: ${String(part.partTypeId)} is not in this system`;
|
||||
if (!type.active) return `INACTIVE_PART_TYPE: ${type.id} is inactive`;
|
||||
for (const insert of part.inserts ?? []) {
|
||||
const nested = checkAddable(insert, catalog);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Deep copy of an incoming part without empty `inserts` arrays. */
|
||||
function normalisedCopy(part: HivePart): HivePart {
|
||||
const copy = structuredClone(part);
|
||||
if (Array.isArray(copy.inserts)) {
|
||||
if (copy.inserts.length === 0) delete copy.inserts;
|
||||
else copy.inserts = copy.inserts.map(normalisedCopy);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
type OpOf<K extends HiveOp["op"]> = Extract<HiveOp, { op: K }>;
|
||||
|
||||
function applyAddPart(state: HiveState, op: OpOf<"addPart">, catalog: HiveCatalog): OpOutcome {
|
||||
const problem = checkAddable(op.part, catalog);
|
||||
if (problem) return problem;
|
||||
state.stack.splice(clamp(op.at, state.stack.length), 0, normalisedCopy(op.part));
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyAddInsert(state: HiveState, op: OpOf<"addInsert">, catalog: HiveCatalog): OpOutcome {
|
||||
const host = locate(state, op.hostId);
|
||||
if (!host || host.isInsert) return missing(op.hostId);
|
||||
const problem = checkAddable(op.part, catalog);
|
||||
if (problem) return problem;
|
||||
host.part.inserts = [...(host.part.inserts ?? []), normalisedCopy(op.part)];
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyRemovePart(state: HiveState, op: OpOf<"removePart">): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
found.list.splice(found.index, 1);
|
||||
if (found.isInsert) {
|
||||
const host = state.stack.find((p) => p.inserts === found.list);
|
||||
if (host && found.list.length === 0) delete host.inserts;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyMovePart(state: HiveState, op: OpOf<"movePart">): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
if (found.isInsert) return `RULE_INSERT_HOST: ${op.id} is an insert and cannot be moved`;
|
||||
const [moved] = state.stack.splice(found.index, 1);
|
||||
state.stack.splice(clamp(op.to, state.stack.length), 0, moved);
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetState(state: HiveState, op: OpOf<"setState">): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
found.part.state = { ...found.part.state, [op.key]: op.value };
|
||||
return null;
|
||||
}
|
||||
|
||||
function locateBox(state: HiveState, boxId: unknown): Location | string {
|
||||
const found = locate(state, boxId);
|
||||
if (!found) return missing(boxId);
|
||||
if (found.isInsert || !Array.isArray(found.part.frames)) {
|
||||
return `FRAMES_LENGTH: ${String(boxId)} is not a box with frames`;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function applySetFrame(state: HiveState, op: OpOf<"setFrame">): OpOutcome {
|
||||
const box = locateBox(state, op.boxId);
|
||||
if (typeof box === "string") return box;
|
||||
const frames = box.part.frames as HiveFrame[];
|
||||
if (!Number.isInteger(op.slot) || op.slot < 1 || op.slot > frames.length) {
|
||||
return `FRAMES_LENGTH: slot ${String(op.slot)} is outside 1..${frames.length}`;
|
||||
}
|
||||
// name: undefined keeps the current name, null removes it.
|
||||
const current = frames[op.slot - 1];
|
||||
const name = op.name === undefined ? current.name : (op.name ?? undefined);
|
||||
frames[op.slot - 1] =
|
||||
name === undefined
|
||||
? { slot: op.slot, type: op.type }
|
||||
: { slot: op.slot, type: op.type, name };
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetFrames(state: HiveState, op: OpOf<"setFrames">): OpOutcome {
|
||||
const box = locateBox(state, op.boxId);
|
||||
if (typeof box === "string") return box;
|
||||
box.part.frames = structuredClone(op.frames);
|
||||
return null;
|
||||
}
|
||||
|
||||
function applySetLabel(state: HiveState, op: OpOf<"setLabel">): OpOutcome {
|
||||
const found = locate(state, op.id);
|
||||
if (!found) return missing(op.id);
|
||||
if (op.label === null) delete found.part.label;
|
||||
else found.part.label = op.label;
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyOne(state: HiveState, op: HiveOp, catalog: HiveCatalog): OpOutcome {
|
||||
switch (op?.op) {
|
||||
case "addPart":
|
||||
return applyAddPart(state, op, catalog);
|
||||
case "addInsert":
|
||||
return applyAddInsert(state, op, catalog);
|
||||
case "removePart":
|
||||
return applyRemovePart(state, op);
|
||||
case "movePart":
|
||||
return applyMovePart(state, op);
|
||||
case "setState":
|
||||
return applySetState(state, op);
|
||||
case "setFrame":
|
||||
return applySetFrame(state, op);
|
||||
case "setFrames":
|
||||
return applySetFrames(state, op);
|
||||
case "setLabel":
|
||||
return applySetLabel(state, op);
|
||||
default:
|
||||
return "UNKNOWN_OP: unsupported operation";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies ops in order and validates the whole state after each one.
|
||||
* Never mutates its inputs: it works on a private deep copy.
|
||||
*/
|
||||
export function applyHiveOps(state: HiveState, ops: HiveOp[], catalog: HiveCatalog): HiveApplyResult {
|
||||
if (ops.length > MAX_OPS) {
|
||||
return {
|
||||
ok: false,
|
||||
opIndex: MAX_OPS,
|
||||
reason: `LIMIT_OPS: at most ${MAX_OPS} operations per request`,
|
||||
};
|
||||
}
|
||||
const working = structuredClone(state);
|
||||
for (let opIndex = 0; opIndex < ops.length; opIndex++) {
|
||||
const failure =
|
||||
applyOne(working, ops[opIndex], catalog) ?? validateHiveState(working, catalog)[0] ?? null;
|
||||
if (failure) return { ok: false, opIndex, reason: failure };
|
||||
}
|
||||
return { ok: true, state: working };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hiveReasonText } from "./reason-text";
|
||||
|
||||
describe("hiveReasonText", () => {
|
||||
it("maps a reason code to a German sentence", () => {
|
||||
expect(hiveReasonText("RULE_FLOOR_BOTTOM: the stack needs exactly one floor")).toBe(
|
||||
"Der Boden muss ganz unten liegen.",
|
||||
);
|
||||
expect(hiveReasonText("MISSING_ID: no part with id x")).toContain("gibt es nicht mehr");
|
||||
});
|
||||
|
||||
it("covers every code the ops and validator can produce", () => {
|
||||
for (const code of [
|
||||
"MISSING_ID", "DUPLICATE_ID", "FOREIGN_PART_TYPE", "INACTIVE_PART_TYPE", "FRAMES_LENGTH",
|
||||
"RULE_INSERT_HOST", "RULE_INSERT_LIMIT", "RULE_FLOOR_BOTTOM", "RULE_ROOF_TOP",
|
||||
"STATE_OUT_OF_RANGE", "STATE_STEP", "STATE_UNKNOWN_KEY", "UNKNOWN_FIELD", "UNKNOWN_OP",
|
||||
"LIMIT_OPS", "LIMIT_PARTS", "LIMIT_TEXT",
|
||||
]) {
|
||||
expect(hiveReasonText(`${code}: x`)).not.toBe("Diese Änderung passt nicht zum Aufbau.");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back for unknown reasons", () => {
|
||||
expect(hiveReasonText("WHATEVER: x")).toBe("Diese Änderung passt nicht zum Aufbau.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
const FALLBACK = "Diese Änderung passt nicht zum Aufbau.";
|
||||
|
||||
const TEXTS: Record<string, string> = {
|
||||
MISSING_ID: "Ein Teil, auf das sich die Änderung bezieht, gibt es nicht mehr.",
|
||||
DUPLICATE_ID: "Ein Teil kommt doppelt vor.",
|
||||
FOREIGN_PART_TYPE: "Diese Teile-Art gehört nicht zum Beutensystem.",
|
||||
INACTIVE_PART_TYPE: "Diese Teile-Art ist ausgeblendet.",
|
||||
FRAMES_LENGTH: "Dieses Rähmchen gibt es in der Zarge nicht.",
|
||||
RULE_INSERT_HOST: "Dieses Teil passt nicht an diese Stelle.",
|
||||
RULE_INSERT_LIMIT: "Davon passt nur eines an diese Stelle.",
|
||||
RULE_FLOOR_BOTTOM: "Der Boden muss ganz unten liegen.",
|
||||
RULE_ROOF_TOP: "Das Dach muss ganz oben liegen.",
|
||||
STATE_OUT_OF_RANGE: "Dieser Wert ist nicht erlaubt.",
|
||||
STATE_STEP: "Dieser Wert passt nicht zur Schrittweite.",
|
||||
STATE_UNKNOWN_KEY: "Dieses Feld gibt es bei diesem Teil nicht.",
|
||||
UNKNOWN_FIELD: "Die Änderung enthält ein unbekanntes Feld.",
|
||||
UNKNOWN_OP: "Diese Änderung wird nicht unterstützt.",
|
||||
LIMIT_OPS: "Zu viele Änderungen auf einmal.",
|
||||
LIMIT_PARTS: "Der Aufbau darf höchstens 30 Teile haben.",
|
||||
LIMIT_TEXT: "Der Name ist zu lang.",
|
||||
};
|
||||
|
||||
/** German sentence for a reason such as „RULE_FLOOR_BOTTOM: …“ from applyHiveOps or the server. */
|
||||
export function hiveReasonText(reason: string): string {
|
||||
const code = reason.split(":")[0].trim();
|
||||
return TEXTS[code] ?? FALLBACK;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveHiveFacts } from "./facts";
|
||||
import { hiveFactsText, shortGermanDay, sourceLabel } from "./summary";
|
||||
import { frames, inlineCatalog, part } from "./test-utils";
|
||||
import type { HiveState } from "./types";
|
||||
|
||||
const catalog = inlineCatalog();
|
||||
|
||||
describe("shortGermanDay", () => {
|
||||
it("writes 2026-10-12 as 12.10.", () => {
|
||||
expect(shortGermanDay("2026-10-12")).toBe("12.10.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sourceLabel", () => {
|
||||
it("names the sources the way the beekeeper knows them", () => {
|
||||
expect(sourceLabel("CARD")).toBe("Karte");
|
||||
expect(sourceLabel("EDITOR")).toBe("Umbau");
|
||||
expect(sourceLabel("INSPECTION")).toBe("Durchsicht");
|
||||
expect(sourceLabel("INIT")).toBe("Angelegt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hiveFactsText", () => {
|
||||
it("lists boxes, brood, feed and the floor insert", () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor", { inserts: [part("fi", "floor-insert")] }),
|
||||
part("b1", "box", { frames: frames(3, "BROOD") }),
|
||||
part("b2", "box", { frames: frames(3, "BROOD") }),
|
||||
part("fe", "feeder", { state: { syrupLiters: 3, fondantKg: 0 } }),
|
||||
part("fe2", "feeder", { state: { syrupLiters: 2, fondantKg: 0 } }),
|
||||
],
|
||||
};
|
||||
expect(hiveFactsText(deriveHiveFacts(state, catalog))).toBe(
|
||||
"2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses singular forms and leaves out what is not there", () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [part("b1", "box", { frames: frames(1, "BROOD") })],
|
||||
};
|
||||
expect(hiveFactsText(deriveHiveFacts(state, catalog))).toBe("1 Zarge · 1 Brutwabe");
|
||||
});
|
||||
|
||||
it("shows acid of an evaporator", () => {
|
||||
const state: HiveState = {
|
||||
schemaVersion: 1,
|
||||
stack: [part("b1", "box", { frames: frames(1), inserts: [part("ev", "evaporator", { state: { acidMl: 40 } })] })],
|
||||
};
|
||||
expect(hiveFactsText(deriveHiveFacts(state, catalog))).toContain("Säure 40 ml");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { HiveChangeSource, HiveFacts } from "./types";
|
||||
|
||||
const SOURCE_LABELS: Record<HiveChangeSource, string> = {
|
||||
INIT: "Angelegt",
|
||||
CARD: "Karte",
|
||||
EDITOR: "Umbau",
|
||||
INSPECTION: "Durchsicht",
|
||||
AI_SUGGESTION: "Vorschlag",
|
||||
};
|
||||
|
||||
export function sourceLabel(source: HiveChangeSource): string {
|
||||
return SOURCE_LABELS[source];
|
||||
}
|
||||
|
||||
/** "2026-10-12" -> "12.10." */
|
||||
export function shortGermanDay(day: string): string {
|
||||
const [, month, date] = day.split("-");
|
||||
return `${date}.${month}.`;
|
||||
}
|
||||
|
||||
const numberFormat = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 3 });
|
||||
|
||||
function plural(count: number, one: string, many: string): string {
|
||||
return `${count} ${count === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
/** Feed levels (liquid and paste) summed per unit: "5 l" or "5 l, 2 kg". */
|
||||
function feedText(facts: HiveFacts): string | null {
|
||||
const perUnit = new Map<string, number>();
|
||||
for (const level of facts.levels) {
|
||||
if (!level.feedType || level.value <= 0) continue;
|
||||
perUnit.set(level.unit, (perUnit.get(level.unit) ?? 0) + level.value);
|
||||
}
|
||||
if (perUnit.size === 0) return null;
|
||||
const parts = [...perUnit].map(([unit, value]) => `${numberFormat.format(value)} ${unit}`);
|
||||
return `Futter ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
/** "2 Zargen · 6 Brutwaben · Futter 5 l · Bausperre drin" (spec 9.4); empty parts are left out. */
|
||||
export function hiveFactsText(facts: HiveFacts): string {
|
||||
const parts: (string | null)[] = [
|
||||
plural(facts.boxCount, "Zarge", "Zargen"),
|
||||
facts.broodFrames > 0 ? plural(facts.broodFrames, "Brutwabe", "Brutwaben") : null,
|
||||
feedText(facts),
|
||||
facts.evaporatorAcidMl !== null ? `Säure ${numberFormat.format(facts.evaporatorAcidMl)} ml` : null,
|
||||
facts.hasFloorInsert ? "Bausperre drin" : null,
|
||||
];
|
||||
return parts.filter((part): part is string => part !== null).join(" · ");
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { catalogFromRows } from "./types";
|
||||
import type { CatalogPartType, HiveCatalog, HiveFrame, HiveOp, HivePart, HiveState } from "./types";
|
||||
|
||||
// Test helpers shared by the hive specs. The backend fixtures sit next to the
|
||||
// frontend in the monorepo; vitest runs from ApiCore-Frontend. The frontend
|
||||
// image build only sees ApiCore-Frontend, so fixture specs skip there.
|
||||
export const FIXTURE_DIR = path.resolve(process.cwd(), "../ApiCore-Backend/test/fixtures/hive-ops");
|
||||
|
||||
export interface FixtureCase {
|
||||
name: string;
|
||||
inactivePartTypeIds?: string[];
|
||||
state: HiveState;
|
||||
ops: HiveOp[];
|
||||
expect:
|
||||
| { ok: true; state: HiveState }
|
||||
| { ok: false; opIndex: number; reasonCode: string };
|
||||
}
|
||||
|
||||
interface CatalogFile {
|
||||
systemId: string;
|
||||
partTypes: CatalogPartType[];
|
||||
}
|
||||
|
||||
export function fixtureDirExists(): boolean {
|
||||
return existsSync(FIXTURE_DIR);
|
||||
}
|
||||
|
||||
export function loadFixtureCases(): { file: string; data: FixtureCase }[] {
|
||||
return readdirSync(FIXTURE_DIR)
|
||||
.filter((file) => file.endsWith(".json") && file !== "catalog.json")
|
||||
.sort()
|
||||
.map((file) => ({
|
||||
file,
|
||||
data: JSON.parse(readFileSync(path.join(FIXTURE_DIR, file), "utf8")) as FixtureCase,
|
||||
}));
|
||||
}
|
||||
|
||||
export function loadFixtureCatalog(inactivePartTypeIds: string[] = []): HiveCatalog {
|
||||
const file = JSON.parse(readFileSync(path.join(FIXTURE_DIR, "catalog.json"), "utf8")) as CatalogFile;
|
||||
const rows = file.partTypes.map((row) => ({
|
||||
...row,
|
||||
active: row.active && !inactivePartTypeIds.includes(row.id),
|
||||
}));
|
||||
return catalogFromRows(rows, file.systemId);
|
||||
}
|
||||
|
||||
// ---- Inline catalog for specs that must run without the backend folder ----
|
||||
|
||||
const noState: CatalogPartType["stateSchema"] = [];
|
||||
|
||||
function type(
|
||||
id: string,
|
||||
kind: CatalogPartType["kind"],
|
||||
lengthMm: number,
|
||||
widthMm: number,
|
||||
heightMm: number,
|
||||
extra: Partial<CatalogPartType> = {},
|
||||
): CatalogPartType {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
name: id,
|
||||
lengthMm,
|
||||
widthMm,
|
||||
heightMm,
|
||||
frameSlots: null,
|
||||
stateSchema: noState,
|
||||
active: true,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mirrors the Zander/Liebig catalog the backend fixtures use. */
|
||||
export function inlineCatalog(): HiveCatalog {
|
||||
return catalogFromRows(
|
||||
[
|
||||
type("floor", "FLOOR", 525, 430, 120),
|
||||
type("floor-insert", "FLOOR_INSERT", 475, 375, 5),
|
||||
type("box", "BOX", 525, 430, 230, { frameSlots: 10 }),
|
||||
type("cover", "COVER_SHEET", 478, 378, 1),
|
||||
type("feeder", "FEEDER", 520, 420, 70, {
|
||||
stateSchema: [
|
||||
{ key: "syrupLiters", label: "Flüssigfutter", kind: "level", unit: "l", max: 3, step: 0.5, feedType: "SUGAR_WATER" },
|
||||
{ key: "fondantKg", label: "Futterteig", kind: "level", unit: "kg", max: 2.5, step: 0.5, feedType: "FEED_PASTE" },
|
||||
],
|
||||
}),
|
||||
type("inner-lid", "INNER_LID", 525, 430, 35, {
|
||||
stateSchema: [
|
||||
{ key: "feedHole", label: "Futterloch", kind: "choice", options: ["OPEN", "CLOSED"], optionLabels: ["offen", "zu"] },
|
||||
],
|
||||
}),
|
||||
type("insulation", "INSULATION", 465, 430, 20),
|
||||
type("roof", "ROOF", 525, 430, 60),
|
||||
type("evaporator", "EVAPORATOR", 200, 150, 120, {
|
||||
stateSchema: [{ key: "acidMl", label: "Säure", kind: "level", unit: "ml", max: 300, step: 10 }],
|
||||
}),
|
||||
],
|
||||
"sys",
|
||||
);
|
||||
}
|
||||
|
||||
export function frames(count: number, kind: HiveFrame["type"] = "FOUNDATION"): HiveFrame[] {
|
||||
return Array.from({ length: count }, (_, index) => ({ slot: index + 1, type: kind }));
|
||||
}
|
||||
|
||||
export function part(id: string, partTypeId: string, extra: Partial<HivePart> = {}): HivePart {
|
||||
return { id, partTypeId, state: {}, ...extra };
|
||||
}
|
||||
|
||||
/** Template of the system: floor, full box, cover, inner lid, insulation, roof. */
|
||||
export function templateState(): HiveState {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
stack: [
|
||||
part("f", "floor"),
|
||||
part("b", "box", { frames: frames(10) }),
|
||||
part("c", "cover"),
|
||||
part("l", "inner-lid"),
|
||||
part("i", "insulation"),
|
||||
part("r", "roof"),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Frontend mirror of ApiCore-Backend/src/hive/hive-types.ts and the API
|
||||
// shapes (spec 5.2, 6.1). Declared here on purpose: the Docker build only
|
||||
// sees ApiCore-Frontend, so nothing is imported from the backend folder.
|
||||
|
||||
export type HivePartKind =
|
||||
| "FLOOR"
|
||||
| "BOX"
|
||||
| "DIVIDER_BOARD"
|
||||
| "QUEEN_EXCLUDER"
|
||||
| "FEEDER"
|
||||
| "COVER_SHEET"
|
||||
| "PROPOLIS_SCREEN"
|
||||
| "INNER_LID"
|
||||
| "INSULATION"
|
||||
| "ROOF"
|
||||
| "FLOOR_INSERT"
|
||||
| "EVAPORATOR";
|
||||
|
||||
export type HiveBoxDepth = "FULL" | "SHALLOW";
|
||||
|
||||
export type FrameType =
|
||||
| "BROOD"
|
||||
| "DRONE"
|
||||
| "HONEY"
|
||||
| "POLLEN"
|
||||
| "FEED"
|
||||
| "FOUNDATION"
|
||||
| "EMPTY_COMB"
|
||||
| "DRONE_FRAME"
|
||||
| "DIVIDER"
|
||||
| "NONE";
|
||||
|
||||
export const FRAME_TYPES: readonly FrameType[] = [
|
||||
"BROOD",
|
||||
"DRONE",
|
||||
"HONEY",
|
||||
"POLLEN",
|
||||
"FEED",
|
||||
"FOUNDATION",
|
||||
"EMPTY_COMB",
|
||||
"DRONE_FRAME",
|
||||
"DIVIDER",
|
||||
"NONE",
|
||||
];
|
||||
|
||||
export const MAX_STACK_PARTS = 30;
|
||||
export const MAX_OPS = 100;
|
||||
export const MAX_TEXT_LENGTH = 40;
|
||||
export const LEVEL_TOLERANCE = 1e-9;
|
||||
|
||||
export interface HiveFrame {
|
||||
slot: number;
|
||||
type: FrameType;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface HivePart {
|
||||
id: string;
|
||||
partTypeId: string;
|
||||
label?: string;
|
||||
state: Record<string, number | string>;
|
||||
inserts?: HivePart[];
|
||||
frames?: HiveFrame[];
|
||||
}
|
||||
|
||||
export interface HiveState {
|
||||
schemaVersion: 1;
|
||||
stack: HivePart[];
|
||||
}
|
||||
|
||||
export type HiveOp =
|
||||
| { op: "addPart"; part: HivePart; at: number }
|
||||
| { op: "addInsert"; hostId: string; part: HivePart }
|
||||
| { op: "removePart"; id: string }
|
||||
| { op: "movePart"; id: string; to: number }
|
||||
| { op: "setState"; id: string; key: string; value: number | string }
|
||||
| {
|
||||
op: "setFrame";
|
||||
boxId: string;
|
||||
slot: number;
|
||||
type: FrameType;
|
||||
name?: string | null;
|
||||
}
|
||||
| { op: "setFrames"; boxId: string; frames: HiveFrame[] }
|
||||
| { op: "setLabel"; id: string; label: string | null };
|
||||
|
||||
export interface LevelField {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "level";
|
||||
unit: string;
|
||||
max: number;
|
||||
step: number;
|
||||
feedType?: string;
|
||||
}
|
||||
|
||||
export interface ChoiceField {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "choice";
|
||||
options: string[];
|
||||
optionLabels: string[];
|
||||
}
|
||||
|
||||
export type StateField = LevelField | ChoiceField;
|
||||
|
||||
export interface CatalogPartType {
|
||||
id: string;
|
||||
kind: HivePartKind;
|
||||
name: string;
|
||||
lengthMm: number;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
frameSlots: number | null;
|
||||
stateSchema: StateField[];
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface HiveCatalog {
|
||||
systemId: string;
|
||||
partTypes: Map<string, CatalogPartType>;
|
||||
}
|
||||
|
||||
export function catalogFromRows(rows: CatalogPartType[], systemId: string): HiveCatalog {
|
||||
return { systemId, partTypes: new Map(rows.map((row) => [row.id, row])) };
|
||||
}
|
||||
|
||||
export interface HiveLevelFact {
|
||||
partId: string;
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
value: number;
|
||||
max: number;
|
||||
feedType?: string;
|
||||
}
|
||||
|
||||
export interface HiveFacts {
|
||||
boxCount: number;
|
||||
broodFrames: number;
|
||||
honeyFrames: number;
|
||||
builtFrames: number;
|
||||
levels: HiveLevelFact[];
|
||||
hasFloorInsert: boolean;
|
||||
evaporatorAcidMl: number | null;
|
||||
}
|
||||
|
||||
export type HiveApplyResult =
|
||||
| { ok: true; state: HiveState }
|
||||
| { ok: false; opIndex: number; reason: string };
|
||||
|
||||
// ---- API shapes (hive.service.ts, hive-catalog.service.ts) ----
|
||||
|
||||
export type HiveChangeSource = "INIT" | "CARD" | "EDITOR" | "INSPECTION" | "AI_SUGGESTION";
|
||||
|
||||
/** Sources a client may send; INIT and INSPECTION are set by the server. */
|
||||
export type ClientHiveChangeSource = "CARD" | "EDITOR";
|
||||
|
||||
export interface HivePartTypeDto extends CatalogPartType {
|
||||
systemId: string;
|
||||
articleRef: string | null;
|
||||
boxDepth: HiveBoxDepth | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HiveSystemDto {
|
||||
id: string;
|
||||
name: string;
|
||||
frameWidthMm: number;
|
||||
frameHeightMm: number;
|
||||
footprintLMm: number;
|
||||
footprintWMm: number;
|
||||
template: unknown;
|
||||
active: boolean;
|
||||
partTypes: HivePartTypeDto[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HiveSetupView {
|
||||
colonyId: string;
|
||||
systemId: string;
|
||||
version: number;
|
||||
state: HiveState;
|
||||
facts: HiveFacts;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface HiveChangeListItem {
|
||||
id: string;
|
||||
version: number;
|
||||
occurredOn: string;
|
||||
source: HiveChangeSource;
|
||||
inspectionId: string | null;
|
||||
userId: string;
|
||||
userName: string;
|
||||
ops: unknown;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface HiveHistoryPage {
|
||||
items: HiveChangeListItem[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface HiveAtView {
|
||||
version: number;
|
||||
state: HiveState;
|
||||
facts: HiveFacts;
|
||||
occurredOn: string;
|
||||
}
|
||||
|
||||
export interface CreateHiveInput {
|
||||
systemId: string;
|
||||
state: HiveState;
|
||||
occurredOn: string;
|
||||
}
|
||||
|
||||
export interface PostHiveChangeInput {
|
||||
occurredOn: string;
|
||||
source: ClientHiveChangeSource;
|
||||
ops: HiveOp[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Body of the 409 HIVE_CONFLICT answer. */
|
||||
export interface HiveConflictBody {
|
||||
code: "HIVE_CONFLICT";
|
||||
opIndex: number;
|
||||
reason: string;
|
||||
current: { version: number; state: HiveState };
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
FRAME_TYPES,
|
||||
LEVEL_TOLERANCE,
|
||||
MAX_STACK_PARTS,
|
||||
MAX_TEXT_LENGTH,
|
||||
type CatalogPartType,
|
||||
type HiveCatalog,
|
||||
type StateField,
|
||||
} from "./types";
|
||||
|
||||
// Port of ApiCore-Backend/src/hive/hive-validate.ts. Check order decides which
|
||||
// code is reported first: root shape (schemaVersion must be 1 ->
|
||||
// STATE_OUT_OF_RANGE; extra root keys -> UNKNOWN_FIELD), LIMIT_PARTS, then per
|
||||
// stack part: id, unknown fields (UNKNOWN_FIELD), type, label, state, frames
|
||||
// (per frame: slot, unknown fields, type, name), inserts (each insert in the
|
||||
// same order), then FLOOR, then ROOF. Text lengths are UTF-16 code units
|
||||
// (`.length`).
|
||||
const ROOT_KEYS = ["schemaVersion", "stack"];
|
||||
const PART_KEYS = ["id", "partTypeId", "label", "state", "inserts", "frames"];
|
||||
const FRAME_KEYS = ["slot", "type", "name"];
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
|
||||
const isObj = (value: unknown): value is Obj =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
function unknownFields(obj: Obj, allowed: string[], where: string, out: string[]): void {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!allowed.includes(key)) out.push(`UNKNOWN_FIELD: ${where}.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks one state value against its schema field; returns a reason or null. */
|
||||
export function checkStateValue(field: StateField, value: unknown, where: string): string | null {
|
||||
if (field.kind === "choice") {
|
||||
if (typeof value !== "string" || !field.options.includes(value)) {
|
||||
return `STATE_OUT_OF_RANGE: ${where} must be one of ${field.options.join("/")}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return `STATE_OUT_OF_RANGE: ${where} must be a number`;
|
||||
}
|
||||
if (value < 0 || value > field.max) {
|
||||
return `STATE_OUT_OF_RANGE: ${where} must be between 0 and ${field.max}`;
|
||||
}
|
||||
const steps = value / field.step;
|
||||
if (Math.abs(steps - Math.round(steps)) * field.step > LEVEL_TOLERANCE) {
|
||||
return `STATE_STEP: ${where} must be a multiple of ${field.step}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateState(part: Obj, type: CatalogPartType, where: string, out: string[]): void {
|
||||
if (!isObj(part.state)) {
|
||||
out.push(`STATE_OUT_OF_RANGE: ${where} has no state object`);
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(part.state)) {
|
||||
const field = type.stateSchema.find((f) => f.key === key);
|
||||
if (!field) {
|
||||
out.push(`STATE_UNKNOWN_KEY: ${where} has unknown key ${key}`);
|
||||
continue;
|
||||
}
|
||||
const problem = checkStateValue(field, value, `${where}.${key}`);
|
||||
if (problem) out.push(problem);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFrame(frame: unknown, index: number, where: string, out: string[]): void {
|
||||
if (!isObj(frame) || frame.slot !== index + 1) {
|
||||
out.push(`FRAMES_LENGTH: ${where} frame ${index + 1} has a wrong slot`);
|
||||
return;
|
||||
}
|
||||
unknownFields(frame, FRAME_KEYS, `${where}.frames[${index}]`, out);
|
||||
if (!(FRAME_TYPES as readonly unknown[]).includes(frame.type)) {
|
||||
out.push(`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} has an unknown type`);
|
||||
}
|
||||
if (frame.name === undefined) return;
|
||||
if (typeof frame.name !== "string") {
|
||||
out.push(`STATE_OUT_OF_RANGE: ${where} frame ${index + 1} name must be a string`);
|
||||
} else if (frame.name.length > MAX_TEXT_LENGTH) {
|
||||
out.push(`LIMIT_TEXT: ${where} frame ${index + 1} name is too long`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateFrames(part: Obj, type: CatalogPartType, where: string, out: string[]): void {
|
||||
const slots = type.kind === "BOX" ? (type.frameSlots ?? 0) : 0;
|
||||
const frames = part.frames;
|
||||
if (type.kind !== "BOX") {
|
||||
if (frames !== undefined && !(Array.isArray(frames) && frames.length === 0)) {
|
||||
out.push(`FRAMES_LENGTH: ${where} is not a box and cannot have frames`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const list: unknown = frames === undefined && slots === 0 ? [] : frames;
|
||||
if (!Array.isArray(list) || list.length !== slots) {
|
||||
out.push(`FRAMES_LENGTH: ${where} needs exactly ${slots} frames`);
|
||||
return;
|
||||
}
|
||||
list.forEach((frame: unknown, index) => validateFrame(frame, index, where, out));
|
||||
}
|
||||
|
||||
interface WalkContext {
|
||||
catalog: HiveCatalog;
|
||||
seenIds: Set<string>;
|
||||
out: string[];
|
||||
}
|
||||
|
||||
/** Checks id, catalog membership, label, state, frames. Returns the type or null. */
|
||||
function validatePartCommon(
|
||||
part: unknown,
|
||||
where: string,
|
||||
ctx: WalkContext,
|
||||
): { part: Obj; type: CatalogPartType } | null {
|
||||
const { out } = ctx;
|
||||
if (!isObj(part)) {
|
||||
out.push(`MISSING_ID: ${where} is not an object`);
|
||||
return null;
|
||||
}
|
||||
if (typeof part.id !== "string" || part.id === "") {
|
||||
out.push(`MISSING_ID: ${where} has no id`);
|
||||
} else if (ctx.seenIds.has(part.id)) {
|
||||
out.push(`DUPLICATE_ID: ${part.id}`);
|
||||
} else {
|
||||
ctx.seenIds.add(part.id);
|
||||
}
|
||||
unknownFields(part, PART_KEYS, where, out);
|
||||
const type =
|
||||
typeof part.partTypeId === "string" ? ctx.catalog.partTypes.get(part.partTypeId) : undefined;
|
||||
if (!type) {
|
||||
out.push(`FOREIGN_PART_TYPE: ${where} part type is not in this system`);
|
||||
return null;
|
||||
}
|
||||
if (part.label !== undefined) {
|
||||
if (typeof part.label !== "string") {
|
||||
out.push(`STATE_OUT_OF_RANGE: ${where} label must be a string`);
|
||||
} else if (part.label.length > MAX_TEXT_LENGTH) {
|
||||
out.push(`LIMIT_TEXT: ${where} label is too long`);
|
||||
}
|
||||
}
|
||||
validateState(part, type, where, out);
|
||||
validateFrames(part, type, where, out);
|
||||
return { part, type };
|
||||
}
|
||||
|
||||
function allowedInsertKind(hostType: CatalogPartType): string | null {
|
||||
if (hostType.kind === "FLOOR") return "FLOOR_INSERT";
|
||||
if (hostType.kind === "BOX") return "EVAPORATOR";
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateInserts(host: Obj, hostType: CatalogPartType, where: string, ctx: WalkContext): void {
|
||||
const { out } = ctx;
|
||||
const inserts = host.inserts;
|
||||
if (inserts === undefined) return;
|
||||
if (!Array.isArray(inserts)) {
|
||||
out.push(`RULE_INSERT_HOST: ${where} inserts must be a list`);
|
||||
return;
|
||||
}
|
||||
const allowed = allowedInsertKind(hostType);
|
||||
if (inserts.length > 0 && allowed === null) {
|
||||
out.push(`RULE_INSERT_HOST: ${where} cannot hold inserts`);
|
||||
}
|
||||
if (allowed !== null && inserts.length > 1) {
|
||||
out.push(`RULE_INSERT_LIMIT: ${where} holds at most one ${allowed}`);
|
||||
}
|
||||
inserts.forEach((insert: unknown, index) => {
|
||||
const here = `${where}.inserts[${index}]`;
|
||||
const checked = validatePartCommon(insert, here, ctx);
|
||||
if (!checked) return;
|
||||
if (allowed !== null && checked.type.kind !== allowed) {
|
||||
out.push(`RULE_INSERT_HOST: ${here} must be ${allowed}`);
|
||||
}
|
||||
const nested = checked.part.inserts;
|
||||
if (nested !== undefined && (!Array.isArray(nested) || nested.length > 0)) {
|
||||
out.push(`RULE_INSERT_HOST: ${here} cannot hold inserts`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateStackRules(kinds: (CatalogPartType | null)[], out: string[]): void {
|
||||
const floors = kinds.filter((t) => t?.kind === "FLOOR").length;
|
||||
if (floors !== 1 || kinds[0]?.kind !== "FLOOR") {
|
||||
out.push("RULE_FLOOR_BOTTOM: the stack needs exactly one floor at the bottom");
|
||||
}
|
||||
const roofIndexes = kinds.map((t, i) => (t?.kind === "ROOF" ? i : -1)).filter((i) => i >= 0);
|
||||
if (roofIndexes.length > 1 || (roofIndexes.length === 1 && roofIndexes[0] !== kinds.length - 1)) {
|
||||
out.push("RULE_ROOF_TOP: the roof must be the single top part");
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates a whole hive state. Empty result means valid. */
|
||||
export function validateHiveState(state: unknown, catalog: HiveCatalog): string[] {
|
||||
const out: string[] = [];
|
||||
if (!isObj(state) || state.schemaVersion !== 1 || !Array.isArray(state.stack)) {
|
||||
return ["STATE_OUT_OF_RANGE: state needs schemaVersion 1 and a stack list"];
|
||||
}
|
||||
unknownFields(state, ROOT_KEYS, "state", out);
|
||||
if (state.stack.length > MAX_STACK_PARTS) {
|
||||
out.push(`LIMIT_PARTS: at most ${MAX_STACK_PARTS} parts in the stack`);
|
||||
}
|
||||
const ctx: WalkContext = { catalog, seenIds: new Set(), out };
|
||||
const types: (CatalogPartType | null)[] = [];
|
||||
state.stack.forEach((entry: unknown, index) => {
|
||||
const where = `stack[${index}]`;
|
||||
const checked = validatePartCommon(entry, where, ctx);
|
||||
types.push(checked?.type ?? null);
|
||||
if (!checked) return;
|
||||
if (checked.type.kind === "FLOOR_INSERT" || checked.type.kind === "EVAPORATOR") {
|
||||
out.push(`RULE_INSERT_HOST: ${where} must be an insert, not a stack part`);
|
||||
}
|
||||
validateInserts(checked.part, checked.type, where, ctx);
|
||||
});
|
||||
validateStackRules(types, out);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useEffectiveMotionLevel, useMotionPreference } from "./motion-preference";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
function mockReduceMotion(initial: boolean) {
|
||||
let matches = initial;
|
||||
const listeners = new Set<Listener>();
|
||||
const mql = {
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
addEventListener: (_: string, listener: Listener) => listeners.add(listener),
|
||||
removeEventListener: (_: string, listener: Listener) => listeners.delete(listener),
|
||||
};
|
||||
window.matchMedia = (() => mql) as unknown as typeof window.matchMedia;
|
||||
return {
|
||||
listenerCount: () => listeners.size,
|
||||
set(next: boolean) {
|
||||
matches = next;
|
||||
listeners.forEach((listener) => listener());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useEffectiveMotionLevel", () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
it("returns subtle when reduce-motion matches and the stored level is full", () => {
|
||||
mockReduceMotion(true);
|
||||
window.localStorage.setItem("apicore.motion", "full");
|
||||
|
||||
const { result } = renderHook(() => useEffectiveMotionLevel());
|
||||
|
||||
expect(result.current).toBe("subtle");
|
||||
});
|
||||
|
||||
it("updates after the media change event", () => {
|
||||
const media = mockReduceMotion(false);
|
||||
const { result } = renderHook(() => useEffectiveMotionLevel());
|
||||
expect(result.current).toBe("full");
|
||||
|
||||
act(() => media.set(true));
|
||||
|
||||
expect(result.current).toBe("subtle");
|
||||
});
|
||||
|
||||
it("updates on storage events for the motion key", () => {
|
||||
mockReduceMotion(false);
|
||||
const { result } = renderHook(() => useEffectiveMotionLevel());
|
||||
|
||||
act(() => {
|
||||
window.localStorage.setItem("apicore.motion", "off");
|
||||
window.dispatchEvent(new StorageEvent("storage", { key: "apicore.motion" }));
|
||||
});
|
||||
|
||||
expect(result.current).toBe("off");
|
||||
});
|
||||
|
||||
it("updates when setLevel of useMotionPreference is called in the same tab", () => {
|
||||
mockReduceMotion(false);
|
||||
const effective = renderHook(() => useEffectiveMotionLevel());
|
||||
const preference = renderHook(() => useMotionPreference());
|
||||
|
||||
act(() => preference.result.current[1]("off"));
|
||||
|
||||
expect(effective.result.current).toBe("off");
|
||||
});
|
||||
|
||||
it("stops listening after unmount", () => {
|
||||
const media = mockReduceMotion(false);
|
||||
const remove = vi.spyOn(window, "removeEventListener");
|
||||
const { unmount } = renderHook(() => useEffectiveMotionLevel());
|
||||
expect(media.listenerCount()).toBe(1);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(media.listenerCount()).toBe(0);
|
||||
const removed = remove.mock.calls.map(([type]) => type);
|
||||
expect(removed).toContain("storage");
|
||||
expect(removed).toContain("apicore:motion");
|
||||
remove.mockRestore();
|
||||
});
|
||||
|
||||
it("falls back to the applied document level when storage is unreadable", () => {
|
||||
mockReduceMotion(false);
|
||||
document.documentElement.dataset.motion = "off";
|
||||
const getItem = vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("blocked");
|
||||
});
|
||||
|
||||
try {
|
||||
const { result } = renderHook(() => useEffectiveMotionLevel());
|
||||
|
||||
expect(result.current).toBe("off");
|
||||
} finally {
|
||||
getItem.mockRestore();
|
||||
delete document.documentElement.dataset.motion;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useState, useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* How much the interface moves. A per-device preference (localStorage), not
|
||||
@@ -20,6 +20,9 @@ export const MOTION_LEVELS: MotionLevel[] = ["full", "subtle", "off"];
|
||||
export const DEFAULT_MOTION_LEVEL: MotionLevel = "full";
|
||||
|
||||
const STORAGE_KEY = "apicore.motion";
|
||||
const REDUCE_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
||||
/** Fired on window by setLevel (after applying); "storage" never fires in the own tab. */
|
||||
export const MOTION_EVENT = "apicore:motion";
|
||||
|
||||
function isMotionLevel(value: unknown): value is MotionLevel {
|
||||
return value === "full" || value === "subtle" || value === "off";
|
||||
@@ -30,8 +33,11 @@ export function readMotionLevel(): MotionLevel {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return isMotionLevel(stored) ? stored : DEFAULT_MOTION_LEVEL;
|
||||
} catch {
|
||||
// Private mode / blocked storage: fall back to the default.
|
||||
return DEFAULT_MOTION_LEVEL;
|
||||
// Private mode / blocked storage: keep what is already applied to the
|
||||
// document (setLevel still applies it), else the default.
|
||||
const applied =
|
||||
typeof document === "undefined" ? undefined : document.documentElement.dataset.motion;
|
||||
return isMotionLevel(applied) ? applied : DEFAULT_MOTION_LEVEL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +49,36 @@ export function applyMotionLevel(level: MotionLevel): void {
|
||||
export function effectiveMotionLevel(level: MotionLevel = readMotionLevel()): MotionLevel {
|
||||
const systemReduces =
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
window.matchMedia(REDUCE_MOTION_QUERY).matches;
|
||||
return systemReduces && level === "full" ? "subtle" : level;
|
||||
}
|
||||
|
||||
function subscribeToMotionLevel(onChange: () => void): () => void {
|
||||
const media =
|
||||
typeof window.matchMedia === "function" ? window.matchMedia(REDUCE_MOTION_QUERY) : null;
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
// key === null: storage was cleared.
|
||||
if (event.key === null || event.key === STORAGE_KEY) onChange();
|
||||
};
|
||||
media?.addEventListener("change", onChange);
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(MOTION_EVENT, onChange);
|
||||
return () => {
|
||||
media?.removeEventListener("change", onChange);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(MOTION_EVENT, onChange);
|
||||
};
|
||||
}
|
||||
|
||||
/** Reactive effective level: follows the system setting, other tabs and setLevel. */
|
||||
export function useEffectiveMotionLevel(): MotionLevel {
|
||||
return useSyncExternalStore(
|
||||
subscribeToMotionLevel,
|
||||
() => effectiveMotionLevel(),
|
||||
() => DEFAULT_MOTION_LEVEL,
|
||||
);
|
||||
}
|
||||
|
||||
export function useMotionPreference(): [MotionLevel, (level: MotionLevel) => void] {
|
||||
// Lazy read: the app shell renders only on the client after login, and the
|
||||
// document attribute itself is applied once in Providers.
|
||||
@@ -62,6 +94,7 @@ export function useMotionPreference(): [MotionLevel, (level: MotionLevel) => voi
|
||||
} catch {
|
||||
// Not persisted - still applies for this visit.
|
||||
}
|
||||
window.dispatchEvent(new Event(MOTION_EVENT));
|
||||
}, []);
|
||||
|
||||
return [level, setLevel];
|
||||
|
||||
Reference in new issue
Block a user