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({ui}) }; } 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(); 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(); 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(); 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(); 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(); 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(, 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( <> , 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(, 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(); 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(, 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(); expect(screen.getByRole("img")).toHaveAttribute("aria-label", "Beute ohne Teile"); }); });