feat: hive card with SVG, outline, edit sheets and catalog settings (Beute L2)
- lib/hive: frontend port of the hive domain (ops, validator, facts, layout, follow-ups, describe texts, reason texts) running the backend fixtures, API client - hive card on the colony page (same height as Stammdaten): SVG view, accessible outline tree, frame/part sheets with optimistic save, conflict sheet, history with past states, init from template or another colony, inspection draft mode - follow-up toasts prefilling feeding and treatment forms (treatment end only with an open treatment) - settings page "Beutensysteme" (hive-systems:manage) - NumberStepper decimal steps, reactive motion level hook, holo tokens - spec: owner design decision 2026-10-16 (glass hologram, focus mode) The SVG view is the fallback; the 3D scene follows in L3. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0091b3d28b
commit
c608f5d405
87 files changed
+9493
-43
No files matched your search
@@ -0,0 +1,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