Files
Multi-Debrid-Downloader/tests/ui-primitives.test.tsx
T
Sucukdeluxe 967eab00ae feat(ui): refine download controls and package motion
Move the global package disclosure action into a responsive right-aligned toolbar group and disable it for empty queues. Replace the decorative package title with a compact neutral filter group, coordinate disclosure opacity with height transitions, and make contextual help follow combined pointer and keyboard focus state.
2026-08-24 20:59:50 +02:00

223 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof import("react")>("react");
return { ...actual, useId: () => "context-info-test", useRef: <T,>(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(<Icon name="download" label="Downloads" />);
expect(html).toContain("aria-label=\"Downloads\"");
expect(html).toContain("<svg");
expect(html).toContain("stroke=\"currentColor\"");
expect(html).not.toMatch(/[\u{1F300}-\u{1FAFF}]/u);
});
it("gives toolbar roles and search controls accessible names", () => {
const html = renderToStaticMarkup(
<Toolbar label="Downloadaktionen">
<ToolbarGroup label="Steuerung">
<button type="button">Start</button>
</ToolbarGroup>
<ToolbarSearch label="Downloads durchsuchen" value="paket" onChange={() => {}} />
</Toolbar>
);
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(
<DataTable>
<DataTableHeader>Spalten</DataTableHeader>
<DataTableBody>
<DataTableEmpty title="Keine Einträge" description="Noch sind keine Daten vorhanden." />
</DataTableBody>
<DataTableFooter pageSize={10} rangeLabel="0 von 0" paginationVisible />
</DataTable>
);
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("<table");
expect(html).toContain("10 pro Seite");
expect(html).toContain("0 von 0");
});
it("omits the entire footer when pagination is not visible", () => {
const html = renderToStaticMarkup(
<DataTableFooter pageSize={25} rangeLabel="125 von 80" paginationVisible={false} />
);
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(
<ContextInfoButton
contextName="Downloads"
content={content}
open={false}
onOpenChange={() => {}}
/>
);
expect(html).toBe("");
});
it("renders an accessible trigger and named region for open real help", () => {
const html = renderToStaticMarkup(
<ContextInfoButton
contextName="Downloads"
content={["Vorhandene ", <strong key="help">Download-Hilfe</strong>]}
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 <strong>Download-Hilfe</strong>");
});
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<Record<string, unknown>>;
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<Record<string, unknown>>[];
};
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]);
});
});