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