diff --git a/packages/mini-markdown-editor/package.json b/packages/mini-markdown-editor/package.json index 73661ebabfc031112302396d5f34cb589721f7c0..f05f97409416f76fcfdfc875b9f871b8992fe99e 100644 --- a/packages/mini-markdown-editor/package.json +++ b/packages/mini-markdown-editor/package.json @@ -51,6 +51,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@uiw/codemirror-extensions-events": "^4.23.7", diff --git a/packages/mini-markdown-editor/src/components/Preview/__test__/CopyCodeButton.test.tsx b/packages/mini-markdown-editor/src/components/Preview/__test__/CopyCodeButton.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e3874f99495af3c877caf8fe71e16520507abff --- /dev/null +++ b/packages/mini-markdown-editor/src/components/Preview/__test__/CopyCodeButton.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import CopyButton from "../CopyCodeButton"; + +// Mock navigator.clipboard +beforeEach(() => { + Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("CopyButton 组件测试", () => { + it("应该正确渲染按钮", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + }); + + it("点击按钮后应该调用 navigator.clipboard.writeText", async () => { + render(); + const button = screen.getByRole("button"); + + fireEvent.click(button); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("测试内容"); + }); + }); + + it("点击后应该显示copied state并在2秒后恢复", async () => { + render(); + const button = screen.getByRole("button"); + + fireEvent.click(button); + + // 确保 copied 状态生效 + await waitFor(() => { + expect(button).toHaveClass("copied"); + }); + + // 2秒后 copied 状态应恢复 + await waitFor( + () => { + expect(button).not.toHaveClass("copied"); + }, + { timeout: 2100 }, + ); + }); + + it("短时间内多次点击,复制操作应只触发一次 (防抖测试)", async () => { + render(); + const button = screen.getByRole("button"); + + fireEvent.click(button); + fireEvent.click(button); + fireEvent.click(button); + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/mini-markdown-editor/src/components/Preview/__test__/index.test.tsx b/packages/mini-markdown-editor/src/components/Preview/__test__/index.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..94d0ec496005214b08e15a88929451e423090fe6 --- /dev/null +++ b/packages/mini-markdown-editor/src/components/Preview/__test__/index.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import Preview from "@/components/Preview"; +import { ConfigContext } from "@/components/providers/config-provider"; +import { useEditorContentStore } from "@/store/editor"; +import { handlePreviewScroll } from "@/utils/handle-scroll"; +import { useCopyCode } from "@/hooks/use-copy-code"; + +// Mock store +vi.mock("@/store/editor", () => ({ + useEditorContentStore: vi.fn(() => ({ + scrollWrapper: "preview", + setScrollWrapper: vi.fn(), + setPreviewView: vi.fn(), + editorView: {}, + })), +})); + +// Mock handlePreviewScroll +vi.mock("@/utils/handle-scroll", () => ({ + handlePreviewScroll: vi.fn(), +})); + +// Mock useCopyCode +vi.mock("@/hooks/use-copy-code", () => ({ + useCopyCode: vi.fn(), +})); + +describe("Preview 组件测试", () => { + const mockContent = "# 标题\n\n这是一些测试内容。"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("应该正确渲染 Markdown 转换后的 HTML", () => { + render(); + + // 确保 HTML 内容被正确渲染 + expect(screen.getByText("标题")).toBeInTheDocument(); + expect(screen.getByText("这是一些测试内容。")).toBeInTheDocument(); + }); + + it("当 isSyncScroll 为 true 时,滚动事件应调用 handlePreviewScroll", () => { + const { container } = render(); + const previewElement = container.querySelector(".markdown-editor-preview"); + expect(previewElement).not.toBeNull(); + if (previewElement) { + fireEvent.scroll(previewElement); + expect(handlePreviewScroll).toHaveBeenCalled(); + } + }); + + it("当鼠标进入预览区域时,应该调用 setScrollWrapper", () => { + const setScrollWrapperMock = vi.fn(); + //ts类型助手 + vi.mocked(useEditorContentStore).mockReturnValue({ + scrollWrapper: "editor", + setScrollWrapper: setScrollWrapperMock, + setPreviewView: vi.fn(), + editorView: {}, + }); + + const { container } = render(); + const previewElement = container.querySelector(".markdown-editor-preview"); + expect(previewElement).not.toBeNull(); + + if (previewElement) { + fireEvent.mouseEnter(previewElement); + expect(setScrollWrapperMock).toHaveBeenCalledWith("preview"); + } + }); + + it("应支持主题切换(light 和 dark)", () => { + render( + + + , + ); + + // 由于 `usePreviewTheme` 只影响样式,不容易直接测试,我们只确保组件能够正确渲染 + expect(screen.getByText("标题")).toBeInTheDocument(); + }); + + it("useCopyCode应被调用", () => { + render(); + expect(useCopyCode).toHaveBeenCalled(); + }); +}); diff --git a/packages/mini-markdown-editor/src/components/Sidebar/__test__/Contents.test.tsx b/packages/mini-markdown-editor/src/components/Sidebar/__test__/Contents.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4df464548dcc3a67ed8acc197e567e96c1e8d027 --- /dev/null +++ b/packages/mini-markdown-editor/src/components/Sidebar/__test__/Contents.test.tsx @@ -0,0 +1,99 @@ +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { vi, describe, test, expect, beforeEach } from "vitest"; +import Contents from "../Contents"; +import { useEditorContentStore } from "@/store/editor"; + +// Mock zustand store +vi.mock("@/store/editor", () => ({ + useEditorContentStore: vi.fn(), +})); + +describe("Contents 组件测试", () => { + //mock data + const mockPreviewElement = document.createElement("div"); + const mockH1 = document.createElement("h1"); + mockH1.innerText = "Title 1"; + mockH1.setAttribute("data-line", "1"); + const mockH2 = document.createElement("h2"); + mockH2.innerText = "Title 2"; + mockH2.setAttribute("data-line", "2"); + + const mockHeaders = [mockH1, mockH2] as unknown as NodeListOf; + + mockPreviewElement.appendChild(mockH1); + mockPreviewElement.appendChild(mockH2); + + beforeEach(() => { + // Reset DOM + document.body.innerHTML = ""; + mockPreviewElement.classList.add("markdown-editor-preview"); + // Mock store + (useEditorContentStore as any).mockImplementation((selector: any) => + selector({ previewView: mockPreviewElement }), + ); + document.body.appendChild(mockPreviewElement); + }); + + test("应正确渲染目录结构", async () => { + render(); + expect(screen.getAllByRole("link")).toHaveLength(2); + expect(screen.getByText("Title 1")).toBeInTheDocument(); + expect(screen.getByText("Title 2")).toBeInTheDocument(); + }); + + test("应处理滚动高亮逻辑", async () => { + render(); + + // 模拟滚动位置 + mockHeaders[0].getBoundingClientRect = () => ({ top: 100 }) as DOMRect; + mockHeaders[1].getBoundingClientRect = () => ({ top: 50 }) as DOMRect; + + await act(async () => { + mockPreviewElement.dispatchEvent(new Event("scroll")); + }); + + // 验证第二个标题被激活 + const links = screen.getAllByRole("link"); + expect(links[1].classList.contains("ant-anchor-link-title-active")).toBe(true); + }); + + test("应处理锚点点击事件", async () => { + const user = userEvent.setup(); + const mockScroll = vi.fn(); + HTMLElement.prototype.scrollIntoView = mockScroll; + + render(); + + const firstLink = screen.getByText("Title 1"); + await user.click(firstLink); + + expect(mockScroll).toHaveBeenCalledWith({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + expect(firstLink.classList.contains("ant-anchor-link-title-active")).toBe(true); + }); + + test("无预览容器时不渲染内容", () => { + document.body.innerHTML = ""; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test("内容变化时更新目录", async () => { + const { rerender } = render(); + + const mockH3 = document.createElement("h3"); + mockH3.innerText = "Title 3"; + mockH3.setAttribute("data-line", "3"); + mockPreviewElement.appendChild(mockH3); + //让组件重新渲染 + await act(async () => { + mockPreviewElement.dispatchEvent(new Event("scroll")); + }); + rerender(); + expect(screen.getAllByRole("link")).toHaveLength(3); + }); +}); diff --git a/packages/mini-markdown-editor/src/components/providers/__test__/config-provider.test.tsx b/packages/mini-markdown-editor/src/components/providers/__test__/config-provider.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..95e417ed25fceb66aa70c6dad9b679df8a450411 --- /dev/null +++ b/packages/mini-markdown-editor/src/components/providers/__test__/config-provider.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { ConfigProvider, ConfigContext } from "../config-provider"; +import { defaultGlobalConfig } from "@/config/global"; +import { GlobalConfig } from "@/types/global-config"; +import { useContext } from "react"; + +// 创建一个测试组件来消费 Context +const TestComponent = () => { + const config = useContext(ConfigContext); + return
{JSON.stringify(config)}
; +}; + +describe("ConfigProvider 组件测试", () => { + it("应该提供默认的配置", () => { + render( + + + , + ); + + const displayedConfig = screen.getByTestId("config-value").textContent; + expect(displayedConfig).toEqual(JSON.stringify(defaultGlobalConfig)); + }); + + it("应该支持自定义配置", () => { + const customConfig: GlobalConfig = { + theme: "dark", + //不需本体存储 + local: false, + }; + + render( + + + , + ); + + const displayedConfig = screen.getByTestId("config-value").textContent; + expect(displayedConfig).toContain('"theme":"dark"'); + expect(displayedConfig).toContain('"local":false'); + }); + + it("自定义配置应与默认配置合并", () => { + const customConfig: Partial = { + theme: "dark", + }; + + render( + + + , + ); + + const displayedConfig = screen.getByTestId("config-value").textContent; + + // 确保 "theme" 覆盖默认值 + expect(displayedConfig).toContain('"theme":"dark"'); + + // 但其他默认配置仍然存在 + Object.keys(defaultGlobalConfig).forEach((key) => { + if (key !== "theme") { + expect(displayedConfig).toContain(`"${key}":`); + } + }); + }); +}); diff --git a/packages/mini-markdown-editor/src/components/providers/__test__/hotkeys-provider.test.tsx b/packages/mini-markdown-editor/src/components/providers/__test__/hotkeys-provider.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..72ec466c1a86cd0383a7c0bf738b005b4dc78197 --- /dev/null +++ b/packages/mini-markdown-editor/src/components/providers/__test__/hotkeys-provider.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useContext } from "react"; +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { HotkeysProvider, HotkeysContext } from "../hotkeys-provider"; +import { Hotkey } from "@/common/hotkeys"; + +// 测试组件:用于触发上下文方法 +const TestConsumer = ({ handle = () => console.log("It is pressed") }) => { + const { registerHandler, unregisterHandler, setEnabled } = useContext(HotkeysContext); + return ( +
+ + + +
+ ); +}; + +describe("HotkeysProvider 组件测试", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + // 测试基础功能 + test("应正确提供上下文方法", () => { + render( + + + , + ); + + expect(screen.getByText("Register Bold")).toBeInTheDocument(); + expect(screen.getByText("Unregister Bold")).toBeInTheDocument(); + }); + + // 测试快捷键注册与触发 + test("当注册快捷键并触发时,应调用处理程序", async () => { + const mockHandler = vi.fn(); + const user = userEvent.setup(); + + render( + + + , + ); + + // 注册快捷键 + await user.click(screen.getByText("Register Bold")); + + // 触发快捷键 + await user.keyboard("{Control>}b{/Control}"); + expect(mockHandler).toHaveBeenCalledTimes(1); + }); + + // 测试禁用状态 + test("当禁用快捷键时,处理程序不应被触发", async () => { + const mockHandler = vi.fn(); + const user = userEvent.setup(); + + render( + + + , + ); + + // 注册并禁用 + await user.click(screen.getByText("Register Bold")); + await user.click(screen.getByText("Disable")); + + // 触发快捷键 + await user.keyboard("{Control>}b{/Control}"); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + // 测试注销功能 + test("当注销快捷键后,处理程序不再响应", async () => { + const mockHandler = vi.fn(); + const user = userEvent.setup(); + + render( + + + , + ); + + // 注册 + await user.click(screen.getByText("Register Bold")); + + // 触发快捷键 + await user.keyboard("{Control>}b{/Control}"); + expect(mockHandler).toHaveBeenCalledTimes(1); + + // 注销 + await user.click(screen.getByText("Unregister Bold")); + await user.keyboard("{Control>}b{/Control}"); + expect(mockHandler).toHaveBeenCalledTimes(1); // 调用次数不变 + }); +}); diff --git a/packages/mini-markdown-editor/src/components/providers/__test__/toolbar-provider.test.tsx b/packages/mini-markdown-editor/src/components/providers/__test__/toolbar-provider.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2a26151d465a0a57f98fb786c6ad56b0210e0b1f --- /dev/null +++ b/packages/mini-markdown-editor/src/components/providers/__test__/toolbar-provider.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from "@testing-library/react"; +import { useContext, useEffect, useState } from "react"; +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { ToolbarProvider, ToolbarContext } from "../toolbar-provider"; +import type { ToolbarContextValues, ToolbarItem } from "@/types/toolbar"; +import { toolbarConfig } from "@/config/toolbar"; +// 模拟工具栏配置模块 +vi.mock("@/config/toolbar", () => ({ + toolbarConfig: { + getAllToolbars: vi.fn(() => [ + { type: "file", title: "文件" }, + { type: "edit", title: "编辑" }, + ]), + }, +})); +// 测试用消费者组件 +const TestConsumer = () => { + const context = useContext(ToolbarContext); + const [testItems, setTestItems] = useState([]); + + useEffect(() => { + if (context?.toolbars) { + setTestItems(context.toolbars); + } + }, [context]); + + return ( +
+
{testItems.length}
+
{testItems[0]?.title}
+
+ ); +}; + +describe("ToolbarProvider 组件测试", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("应正确初始化工具栏配置", async () => { + // 渲染组件 + render( + + + , + ); + + // 验证初始化调用 + expect(await screen.findByTestId("toolbar-count")).toHaveTextContent("2"); + expect(await screen.findByTestId("first-toolbar")).toHaveTextContent("文件"); + }); + + test("应提供有效的上下文值", () => { + let contextValue: ToolbarContextValues = { toolbars: [] }; + // 直接访问上下文的辅助组件 + const ContextChecker = () => { + contextValue = useContext(ToolbarContext)!; + return null; + }; + + render( + + + , + ); + + expect(contextValue).toBeDefined(); + expect(contextValue?.toolbars).toHaveLength(2); + expect(contextValue?.toolbars[1].type).toBe("edit"); + }); + + test("应只在挂载时加载配置", async () => { + const { rerender } = render( + + + , + ); + + // 初始调用 + expect(vi.mocked(toolbarConfig.getAllToolbars)).toHaveBeenCalledTimes(1); + + // 重新渲染 + rerender( + + + , + ); + + // 验证没有重复调用 + expect(toolbarConfig.getAllToolbars).toHaveBeenCalledTimes(1); + }); +});