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;