Files
ApiCore/ApiCore-Frontend/src/components/hive/hive-svg.spec.tsx
T
Nick SzaunigandClaude Opus 5.5 c608f5d405 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]>
2026-10-16 09:12:03 +02:00

149 lines
6.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { hiveLayout } from "@/lib/hive/layout";
import { frames, inlineCatalog, part } from "@/lib/hive/test-utils";
import type { HiveState } from "@/lib/hive/types";
import { HiveSvg, projectPoint } from "./hive-svg";
import { createHiveViewStore, HiveViewProvider } from "./use-hive-view";
const catalog = inlineCatalog();
const state: HiveState = {
schemaVersion: 1,
stack: [
part("f", "floor"),
part("b1", "box", {
frames: [
{ slot: 1, type: "BROOD", name: "Königin" },
{ slot: 2, type: "HONEY" },
{ slot: 3, type: "NONE" },
],
inserts: [part("e1", "evaporator")],
}),
part("b2", "box", { frames: frames(10) }),
part("l", "inner-lid"),
part("r", "roof"),
],
};
const layout = hiveLayout(state, catalog);
function renderSvg(ui: ReactNode, store = createHiveViewStore()) {
return { store, ...render(<HiveViewProvider store={store}>{ui}</HiveViewProvider>) };
}
describe("projectPoint", () => {
it("applies the oblique projection with y down", () => {
expect(projectPoint([2, 3, 4])).toEqual([4, -(3 - 0.35 * 4)]);
expect(projectPoint([0, 0, 0])[0]).toBe(0);
});
});
describe("HiveSvg", () => {
it("is an img with a summary label and never crops", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
const svg = container.querySelector("svg")!;
expect(svg).toHaveAttribute("role", "img");
expect(svg).toHaveAttribute("preserveAspectRatio", "xMidYMid meet");
expect(svg.getAttribute("aria-label")).toBe("Beute mit 2 Zargen, Boden, Holzdeckel, Dach");
const [x, y, w, h] = svg.getAttribute("viewBox")!.split(" ").map(Number);
expect(w).toBeGreaterThan(0);
expect(h).toBeGreaterThan(0);
expect(Number.isFinite(x + y)).toBe(true);
});
it("fits the viewBox with 5 % padding around the projected bounds", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
const [x, , w] = container.querySelector("svg")!.getAttribute("viewBox")!.split(" ").map(Number);
const left = Math.min(...layout.parts.map((p) => p.center[0] - p.size[0] / 2 + 0.5 * (-p.size[2] / 2)));
const right = Math.max(...layout.parts.map((p) => p.center[0] + p.size[0] / 2 + 0.5 * (p.size[2] / 2)));
const width = right - left;
expect(x).toBeCloseTo(left - 0.05 * width, 5);
expect(w).toBeCloseTo(width * 1.1, 5);
});
it("draws one group per part with data-part-id and no frames while no box is open", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
const ids = [...container.querySelectorAll("g[data-part-id]")].map((g) => g.getAttribute("data-part-id"));
expect(ids).toEqual(["f", "b1", "b2", "l", "r", "e1"]);
expect(container.querySelectorAll("[data-frame-slot]")).toHaveLength(0);
});
it("is not in the tab order and has no button roles without handlers", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
expect(container.querySelectorAll("[role=button]")).toHaveLength(0);
expect(container.querySelectorAll('[tabindex="0"]')).toHaveLength(0);
});
it("makes parts buttons with tabIndex -1 when onSelectPart is given", () => {
const onSelectPart = vi.fn();
const { container } = renderSvg(<HiveSvg layout={layout} onSelectPart={onSelectPart} />);
const group = container.querySelector('g[data-part-id="b2"]')!;
expect(group).toHaveAttribute("role", "button");
expect(group).toHaveAttribute("tabindex", "-1");
fireEvent.click(group);
expect(onSelectPart).toHaveBeenCalledWith("b2");
});
it("draws frames only for the open box, each with its type word as title", () => {
const store = createHiveViewStore();
store.openBox("b1");
const { container } = renderSvg(<HiveSvg layout={layout} />, store);
const slots = [...container.querySelectorAll("[data-frame-slot]")];
expect(slots).toHaveLength(3);
const titles = slots.map((el) => el.querySelector("title")!.textContent);
expect(titles[0]).toContain("Brutwabe");
expect(titles[0]).toContain("Königin");
expect(titles[1]).toContain("Honigwabe");
expect(titles[2]).toContain("leer");
expect(container.querySelectorAll('[data-frame-box="b2"]')).toHaveLength(0);
});
it("namespaces pattern ids and references them from the frames", () => {
const store = createHiveViewStore();
store.openBox("b1");
const { container } = renderSvg(
<>
<HiveSvg layout={layout} />
<HiveSvg layout={layout} />
</>,
store,
);
const ids = [...container.querySelectorAll("pattern")].map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
const fill = container.querySelector("[data-frame-slot='1'] rect")!.getAttribute("fill")!;
const target = /url\(#(.+)\)/.exec(fill)![1];
expect(container.querySelector(`pattern[id="${target}"]`)).not.toBeNull();
});
it("selects frames when onSelectFrame is given", () => {
const store = createHiveViewStore();
store.openBox("b1");
const onSelectFrame = vi.fn();
const { container } = renderSvg(<HiveSvg layout={layout} onSelectFrame={onSelectFrame} />, store);
const frame = container.querySelector("[data-frame-slot='2']")!;
expect(frame).toHaveAttribute("role", "button");
expect(frame).toHaveAttribute("tabindex", "-1");
fireEvent.click(frame);
expect(onSelectFrame).toHaveBeenCalledWith("b1", 2);
});
it("uses currentColor based strokes and no hard-coded colours", () => {
const { container } = renderSvg(<HiveSvg layout={layout} />);
expect(container.innerHTML).not.toMatch(/#[0-9a-f]{3,6}\b|rgb\(/i);
expect(container.querySelector("path")?.getAttribute("class")).toContain("stroke-current");
});
it("marks the hovered part", () => {
const store = createHiveViewStore();
store.setHovered("l");
const { container } = renderSvg(<HiveSvg layout={layout} />, store);
expect(container.querySelector('g[data-part-id="l"]')).toHaveAttribute("data-hovered", "true");
expect(container.querySelector('g[data-part-id="r"]')).not.toHaveAttribute("data-hovered");
});
it("renders an empty hive without crashing", () => {
renderSvg(<HiveSvg layout={hiveLayout({ schemaVersion: 1, stack: [] }, catalog)} />);
expect(screen.getByRole("img")).toHaveAttribute("aria-label", "Beute ohne Teile");
});
});