import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import { ContextInfoButton } from "../src/renderer/ui/ContextInfoButton"; import { DataTable, DataTableBody, DataTableEmpty, DataTableFooter, DataTableHeader } from "../src/renderer/ui/DataTable"; import { Icon } from "../src/renderer/ui/Icon"; import { Toolbar, ToolbarGroup, ToolbarSearch } from "../src/renderer/ui/Toolbar"; import { getThemeVariables, UI_FOCUS_RING_VARIABLE } from "../src/renderer/ui/theme"; vi.mock("react", async () => { const actual = await vi.importActual("react"); return { ...actual, useId: () => "context-info-test", useRef: (value: T) => ({ current: value }) }; }); const expectedThemes = { dark: { "--ui-canvas": "#0F0F0F", "--ui-surface": "#232323", "--ui-input": "#2B2B2B", "--ui-table-header": "#313131", "--ui-active": "#333436", "--ui-hover": "#373535", "--ui-tooltip": "#4F4D4D", "--ui-border": "#3D3D3D", "--ui-text": "#FFFFFF", "--ui-text-secondary": "#EAEDF3", "--ui-text-muted": "#919191", "--ui-primary": "#BAD0FC", "--ui-primary-hover": "#8AA5DC", "--ui-accent": "#3886FF", "--ui-warning": "#F1C786", "--ui-danger": "#F06464", "--ui-modal-secondary": "#35383D", "--ui-overlay": "rgba(0, 0, 0, 0.60)" }, light: { "--ui-canvas": "#F3F4F6", "--ui-surface": "#FFFFFF", "--ui-input": "#F7F8FA", "--ui-table-header": "#E7E9ED", "--ui-active": "#DEE6F5", "--ui-hover": "#E8ECF3", "--ui-tooltip": "#35383D", "--ui-border": "#D0D4DB", "--ui-text": "#181A1F", "--ui-text-secondary": "#343842", "--ui-text-muted": "#667085", "--ui-primary": "#A9C2F3", "--ui-primary-hover": "#8AA5DC", "--ui-accent": "#256FDB", "--ui-warning": "#E8B85D", "--ui-danger": "#D94747", "--ui-modal-secondary": "#E7E9ED", "--ui-overlay": "rgba(0, 0, 0, 0.45)" } } as const; function relativeLuminance(color: string): number { const channels = color.slice(1).match(/.{2}/g)?.map((value) => Number.parseInt(value, 16) / 255) ?? []; const [red, green, blue] = channels.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4); return 0.2126 * red + 0.7152 * green + 0.0722 * blue; } function contrastRatio(first: string, second: string): number { const lighter = Math.max(relativeLuminance(first), relativeLuminance(second)); const darker = Math.min(relativeLuminance(first), relativeLuminance(second)); return (lighter + 0.05) / (darker + 0.05); } describe("semantic themes", () => { it("exposes the exact frozen semantic roles for dark and light", () => { const dark = getThemeVariables("dark"); const light = getThemeVariables("light"); expect(dark).toEqual(expectedThemes.dark); expect(light).toEqual(expectedThemes.light); expect(Object.keys(dark)).toEqual(Object.keys(light)); expect(Object.keys(dark)).toHaveLength(18); expect(Object.isFrozen(dark)).toBe(true); expect(Object.isFrozen(light)).toBe(true); }); it("uses a focus ring role with at least 3 to 1 contrast on control surfaces", () => { for (const theme of ["dark", "light"] as const) { const variables = getThemeVariables(theme); const focus = variables[UI_FOCUS_RING_VARIABLE]; for (const surface of ["--ui-canvas", "--ui-surface", "--ui-input", "--ui-active", "--ui-hover"] as const) { expect(contrastRatio(focus, variables[surface]), `${theme} focus on ${surface}`).toBeGreaterThanOrEqual(3); } } }); }); describe("new UI primitives", () => { it("renders labelled current-color outline icons without emoji text", () => { const html = renderToStaticMarkup(); expect(html).toContain("aria-label=\"Downloads\""); expect(html).toContain(" { const html = renderToStaticMarkup( {}} /> ); expect(html).toContain("role=\"toolbar\""); expect(html).toContain("aria-label=\"Downloadaktionen\""); expect(html).toContain("role=\"group\""); expect(html).toContain("aria-label=\"Steuerung\""); expect(html).toContain("type=\"search\""); expect(html).toContain("aria-label=\"Downloads durchsuchen\""); }); it("keeps empty content inside the aria table body", () => { const html = renderToStaticMarkup( Spalten ); expect(html).toContain("role=\"table\""); expect(html).toContain("aria-label=\"Datentabelle\""); expect(html.indexOf("Keine Einträge")).toBeGreaterThan(html.indexOf("data-ui-region=\"table-body\"")); expect(html).not.toContain(" { const html = renderToStaticMarkup( ); expect(html).toBe(""); }); it.each([ ["null", null], ["false", false], ["whitespace", " \n\t"], ["empty array", []], ["nested empty array", [null, false, " ", []]] ])("omits context help for %s content", (_label, content) => { const html = renderToStaticMarkup( {}} /> ); expect(html).toBe(""); }); it("renders an accessible trigger and named region for open real help", () => { const html = renderToStaticMarkup( Download-Hilfe]} open onOpenChange={() => {}} /> ); expect(html).toContain("aria-label=\"Informationen\""); expect(html).toContain("aria-expanded=\"true\""); expect(html).toContain("role=\"region\""); expect(html).toContain("aria-label=\"Informationen zu Downloads\""); expect(html).toContain("Vorhandene Download-Hilfe"); }); it("keeps context help open until both pointer and focus leave the shared region", () => { const changes: boolean[] = []; const component = ContextInfoButton({ contextName: "Downloads", content: "Download-Hilfe", open: false, onOpenChange: (open) => changes.push(open) }) as ReactElement>; const props = component.props as unknown as { onPointerEnter: () => void; onPointerLeave: () => void; onFocus: () => void; onBlur: (event: { currentTarget: { contains: (target: unknown) => boolean }; relatedTarget: unknown }) => void; children: ReactElement>[]; }; const inside = {}; const outside = {}; const button = props.children[0] as ReactElement<{ onClick: () => void }>; props.onPointerEnter(); props.onFocus(); props.onPointerLeave(); props.onBlur({ currentTarget: { contains: (target) => target === inside }, relatedTarget: inside }); props.onBlur({ currentTarget: { contains: (target) => target === inside }, relatedTarget: outside }); props.onPointerEnter(); props.onBlur({ currentTarget: { contains: (target) => target === inside }, relatedTarget: outside }); props.onPointerLeave(); button.props.onClick(); expect(changes).toEqual([true, true, false, true, false, true]); }); });