diff --git a/frontend/src/components/theme-toggle.test.tsx b/frontend/src/components/theme-toggle.test.tsx new file mode 100644 index 0000000..a2c88e1 --- /dev/null +++ b/frontend/src/components/theme-toggle.test.tsx @@ -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( + + + , + ); + + 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); + }); +}); diff --git a/frontend/src/components/ui/button.test.tsx b/frontend/src/components/ui/button.test.tsx new file mode 100644 index 0000000..bb98306 --- /dev/null +++ b/frontend/src/components/ui/button.test.tsx @@ -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(); + 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( + , + ); + 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(); + }); +}); diff --git a/frontend/src/components/ui/dialog.test.tsx b/frontend/src/components/ui/dialog.test.tsx new file mode 100644 index 0000000..a9351fd --- /dev/null +++ b/frontend/src/components/ui/dialog.test.tsx @@ -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( + + 打开对话框 + + 测试标题 +

正文内容

+
+
, + ); + 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()); + }); +}); diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts index 3a91f1f..bc55149 100644 --- a/frontend/test/setup.ts +++ b/frontend/test/setup.ts @@ -19,3 +19,27 @@ Element.prototype.scrollIntoView ??= function scrollIntoView() {}; Element.prototype.hasPointerCapture ??= (() => false) as never; Element.prototype.setPointerCapture ??= (() => {}) 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;