test(frontend): sample component tests for button/dialog/theme-toggle (B2)
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ThemeProvider } from "./theme";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
|
||||||
|
describe("ThemeToggle", () => {
|
||||||
|
it("switches theme via dropdown, persists to localStorage, toggles dark class", async () => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ThemeToggle />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "切换主题" }));
|
||||||
|
await userEvent.click(await screen.findByText("深色"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(localStorage.getItem("ui.theme")).toBe("dark"));
|
||||||
|
expect(document.documentElement.classList.contains("dark")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
it("renders label, exposes variant/size data attrs, fires onClick", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(<Button onClick={onClick}>保存</Button>);
|
||||||
|
const btn = screen.getByRole("button", { name: "保存" });
|
||||||
|
expect(btn).toHaveAttribute("data-variant", "default");
|
||||||
|
expect(btn).toHaveAttribute("data-size", "default");
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies non-default variant/size and blocks clicks when disabled", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Button variant="outline" size="sm" disabled onClick={onClick}>
|
||||||
|
x
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole("button");
|
||||||
|
expect(btn).toHaveAttribute("data-variant", "outline");
|
||||||
|
expect(btn).toHaveAttribute("data-size", "sm");
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
await userEvent.click(btn);
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./dialog";
|
||||||
|
|
||||||
|
describe("Dialog", () => {
|
||||||
|
it("opens via trigger, renders title, closes via Escape (radix under jsdom)", async () => {
|
||||||
|
render(
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger>打开对话框</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>测试标题</DialogTitle>
|
||||||
|
<p>正文内容</p>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "打开对话框" }));
|
||||||
|
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("测试标题")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("正文内容")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,3 +19,27 @@ Element.prototype.scrollIntoView ??= function scrollIntoView() {};
|
|||||||
Element.prototype.hasPointerCapture ??= (() => false) as never;
|
Element.prototype.hasPointerCapture ??= (() => false) as never;
|
||||||
Element.prototype.setPointerCapture ??= (() => {}) as never;
|
Element.prototype.setPointerCapture ??= (() => {}) as never;
|
||||||
Element.prototype.releasePointerCapture ??= (() => {}) as never;
|
Element.prototype.releasePointerCapture ??= (() => {}) as never;
|
||||||
|
|
||||||
|
// jsdom 未实现 matchMedia(theme.tsx 按 prefers-color-scheme 求值):EventTarget 基类覆盖
|
||||||
|
// addEventListener/removeEventListener,addListener/removeListener 是旧版 legacy API 兼容面。
|
||||||
|
class MediaQueryListStub extends EventTarget {
|
||||||
|
media: string;
|
||||||
|
matches: boolean;
|
||||||
|
onchange: ((this: MediaQueryList, ev: MediaQueryListEvent) => unknown) | null = null;
|
||||||
|
|
||||||
|
constructor(media: string) {
|
||||||
|
super();
|
||||||
|
this.media = media;
|
||||||
|
this.matches = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
addListener(listener: (this: MediaQueryList, ev: MediaQueryListEvent) => unknown) {
|
||||||
|
this.addEventListener("change", listener as EventListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeListener(listener: (this: MediaQueryList, ev: MediaQueryListEvent) => unknown) {
|
||||||
|
this.removeEventListener("change", listener as EventListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.matchMedia ??= ((media: string) => new MediaQueryListStub(media)) as unknown as typeof matchMedia;
|
||||||
|
|||||||
Reference in New Issue
Block a user