From e0e04b50e56aa915dc7d6824adea4e9953e288c5 Mon Sep 17 00:00:00 2001 From: link <3399652842@qq.com> Date: Mon, 17 Feb 2025 04:19:28 +0800 Subject: [PATCH 1/3] =?UTF-8?q?test(mini-markdown-editor):=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90hooks=E7=9A=84unit=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/__test__/use-expose-handle.test.ts | 122 ++++++++++++++++++ .../hooks/__test__/use-global-config.test.tsx | 66 ++++++++++ .../use-init-sync-scroll-status.test.ts | 73 +++++++++++ .../use-persist-editor-content.test.ts | 121 +++++++++++++++++ .../hooks/__test__/use-preview-theme.test.ts | 80 ++++++++++++ .../hooks/__test__/use-save-content.test.ts | 82 ++++++++++++ .../__test__/use-sync-editorview.test.ts | 73 +++++++++++ .../src/hooks/__test__/use-toolbar.test.tsx | 62 +++++++++ .../src/hooks/use-expose-handle.ts | 3 +- 9 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 packages/mini-markdown-editor/src/hooks/__test__/use-expose-handle.test.ts create mode 100644 packages/mini-markdown-editor/src/hooks/__test__/use-global-config.test.tsx create mode 100644 packages/mini-markdown-editor/src/hooks/__test__/use-init-sync-scroll-status.test.ts create mode 100644 packages/mini-markdown-editor/src/hooks/__test__/use-persist-editor-content.test.ts create mode 100644 packages/mini-markdown-editor/src/hooks/__test__/use-preview-theme.test.ts create mode 100644 packages/mini-markdown-editor/src/hooks/__test__/use-save-content.test.ts create mode 100644 packages/mini-markdown-editor/src/hooks/__test__/use-sync-editorview.test.ts create mode 100644 packages/mini-markdown-editor/src/hooks/__test__/use-toolbar.test.tsx diff --git a/packages/mini-markdown-editor/src/hooks/__test__/use-expose-handle.test.ts b/packages/mini-markdown-editor/src/hooks/__test__/use-expose-handle.test.ts new file mode 100644 index 0000000..db7802e --- /dev/null +++ b/packages/mini-markdown-editor/src/hooks/__test__/use-expose-handle.test.ts @@ -0,0 +1,122 @@ +import { renderHook } from "@testing-library/react"; +import { EditorView } from "@codemirror/view"; +import { useEditorContentStore } from "@/store/editor"; +import { useExposeHandle } from "../use-expose-handle"; +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { insertContent } from "@/utils/insert-content"; +// 模拟 CodeMirror 的 EditorView 实例 +const mockEditorView = { + state: { + doc: "test content", + selection: { + ranges: [{ from: 0, to: 0 }], + main: { from: 0, to: 0 }, + }, + sliceDoc: vi.fn(), + }, + focus: vi.fn(), + dispatch: vi.fn(), +} as unknown as EditorView; + +// 模拟 Zustand 存储 +vi.mock("@/store/editor", () => ({ + useEditorContentStore: vi.fn(() => ({ + editorView: mockEditorView, + previewView: {}, + })), +})); + +// 模拟插入内容模块 +vi.mock("@/utils/insert-content", () => ({ + insertContent: { + insertContent: vi.fn(), + }, +})); + +describe("useExposeHandle Hook测试", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("应正确暴露所有编辑器操作方法", () => { + const mockRef = { current: null }; + renderHook(() => useExposeHandle(mockRef as any)); + + expect(mockRef.current).toMatchObject({ + setContent: expect.any(Function), + getContent: expect.any(Function), + clear: expect.any(Function), + setCursor: expect.any(Function), + getCursor: expect.any(Function), + getSelection: expect.any(Function), + focus: expect.any(Function), + getEditorInstance: expect.any(Function), + getPreviewInstance: expect.any(Function), + }); + }); + + test("setContent 应正确插入内容", () => { + const mockRef: { current: { setContent: (s: string) => void } | null } = { current: null }; + renderHook(() => useExposeHandle(mockRef as any)); + + mockRef.current?.setContent("new content"); + expect(mockEditorView.focus).toHaveBeenCalled(); + expect(vi.mocked(insertContent.insertContent)).toHaveBeenCalledWith( + "new content", + { anchor: 11, head: 11 }, // "new content" 长度 + ); + }); + + test("getContent 应返回编辑器内容", () => { + const mockRef: { current: { getContent: () => string } | null } = { current: null }; + renderHook(() => useExposeHandle(mockRef as any)); + + const content = mockRef.current?.getContent(); + expect(content).toBe("test content"); + }); + + test("clear 应清空编辑器内容", () => { + const mockRef: { current: { clear: () => void } | null } = { current: null }; + renderHook(() => useExposeHandle(mockRef as any)); + + mockRef.current?.clear(); + expect(mockEditorView.dispatch).toHaveBeenCalledWith({ + changes: { from: 0, to: 12, insert: "" }, // "test content" 长度 + }); + }); + + test("setCursor 应正确处理错误输入", () => { + const mockRef: { current: { setCursor: (num1: number, num2: number) => void } | null } = { + current: null, + }; + renderHook(() => useExposeHandle(mockRef as any)); + expect(() => mockRef.current?.setCursor(5, 10)).toThrowError("start 必须比 end 大"); // start < end + }); + + test("getSelection 应返回选中内容", () => { + const mockRef: { current: { getSelection: () => string } | null } = { current: null }; + renderHook(() => useExposeHandle(mockRef as any)); + mockRef.current?.getSelection(); + expect(mockEditorView.state.sliceDoc).toHaveBeenCalledWith(0, 0); + }); + + test("当编辑器实例不存在时应安全处理", () => { + // 覆盖模拟存储返回空实例 + vi.mocked(useEditorContentStore).mockReturnValueOnce({ editorView: null } as any); + + interface MockRef { + current: { + getContent: () => void; + getCursor: () => void; + setContent: (s: string) => void; + } | null; + } + + const mockRef: MockRef = { current: null }; + renderHook(() => useExposeHandle(mockRef as any)); + + expect(mockRef.current?.getContent()).toBe(""); + expect(mockRef.current?.getCursor()).toEqual({ from: 0, to: 0 }); + expect(() => mockRef.current?.setContent("test")).not.toThrow(); + }); +}); diff --git a/packages/mini-markdown-editor/src/hooks/__test__/use-global-config.test.tsx b/packages/mini-markdown-editor/src/hooks/__test__/use-global-config.test.tsx new file mode 100644 index 0000000..b963f88 --- /dev/null +++ b/packages/mini-markdown-editor/src/hooks/__test__/use-global-config.test.tsx @@ -0,0 +1,66 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, test, expect, vi } from "vitest"; +import { type ReactNode, createContext } from "react"; +import { useGlobalConfig } from "../use-global-config"; +import { ConfigContext } from "@/components/providers/config-provider"; + +// 模拟原始上下文提供者(需保持与源码相同的Context对象) +vi.mock("@/components/providers/config-provider", () => ({ + ConfigContext: createContext(undefined), +})); + +// 创建模拟上下文类型 +type MockConfig = { theme: "light" | "dark" }; + +// 测试用 Provider 组件 +const MockProvider = ({ children, value }: { children: ReactNode; value: MockConfig }) => ( + {children} +); + +describe("useGlobalConfig Hook测试", () => { + test("当存在 ConfigProvider 时返回有效上下文", () => { + const expectedConfig: MockConfig = { theme: "dark" }; + + const { result } = renderHook(() => useGlobalConfig(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toEqual(expectedConfig); + }); + + test("当缺少 ConfigProvider 时抛出指定错误", () => { + // 禁用 React 的错误日志避免测试输出混乱 + const consoleError = vi.spyOn(console, "error").mockImplementation(() => null); + + expect(() => { + renderHook(() => useGlobalConfig()); // 不包裹 Provider + }).toThrowError("GlobalConfig init error"); + + consoleError.mockRestore(); + }); + + test("应响应上下文更新", () => { + const initialConfig: MockConfig = { theme: "light" }; + const updatedConfig: MockConfig = { theme: "dark" }; + + const { result, rerender } = renderHook(() => useGlobalConfig(), { + wrapper: ({ children }) => {children}, + }); + + // 初始值验证 + expect(result.current).toEqual(initialConfig); + + // 更新 Provider 值 + act(() => { + rerender({ + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + }); + waitFor(() => { + // 验证更新后的值 + expect(result.current).toEqual(updatedConfig); + }); + }); +}); diff --git a/packages/mini-markdown-editor/src/hooks/__test__/use-init-sync-scroll-status.test.ts b/packages/mini-markdown-editor/src/hooks/__test__/use-init-sync-scroll-status.test.ts new file mode 100644 index 0000000..daffb1d --- /dev/null +++ b/packages/mini-markdown-editor/src/hooks/__test__/use-init-sync-scroll-status.test.ts @@ -0,0 +1,73 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { useInitSyncScrollStatus } from "../use-init-sync-scroll-status"; +import { SYNC_SCROLL_STATUS } from "@/common"; + +// 模拟 localStorage +const mockLocalStorage = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => (store[key] = value)), + clear: vi.fn(() => (store = {})), + }; +})(); + +// 模拟安全存储模块 +vi.mock("@/utils/storage", () => ({ + safeLocalStorage: () => mockLocalStorage, +})); + +describe("useInitSyncScrollStatus Hook测试", () => { + beforeEach(() => { + mockLocalStorage.clear(); + vi.clearAllMocks(); + }); + + test("应正确初始化默认状态(无本地存储)", () => { + const { result } = renderHook(() => useInitSyncScrollStatus()); + + expect(result.current.isSyncScroll).toBe(true); + expect(mockLocalStorage.setItem).toHaveBeenCalledWith(SYNC_SCROLL_STATUS, "true"); + }); + + test("应正确读取本地存储中的 true 值", () => { + mockLocalStorage.setItem(SYNC_SCROLL_STATUS, "true"); + + const { result } = renderHook(() => useInitSyncScrollStatus()); + expect(result.current.isSyncScroll).toBe(true); + }); + + test("应正确读取本地存储中的 false 值", () => { + mockLocalStorage.setItem(SYNC_SCROLL_STATUS, "false"); + + const { result } = renderHook(() => useInitSyncScrollStatus()); + expect(result.current.isSyncScroll).toBe(false); + }); + + test("更新状态应同时修改本地存储和状态", () => { + const { result } = renderHook(() => useInitSyncScrollStatus()); + + act(() => { + result.current.updateSyncScrollStatus(false); + }); + + expect(result.current.isSyncScroll).toBe(false); + expect(mockLocalStorage.setItem).toHaveBeenCalledWith(SYNC_SCROLL_STATUS, "false"); + }); + + test("初始化时应只执行一次存储设置", () => { + renderHook(() => useInitSyncScrollStatus()); + renderHook(() => useInitSyncScrollStatus()); // 二次渲染 + + // 验证 setItem 只调用一次(初始设置) + expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1); + }); + + test("应正确处理非法存储值", () => { + mockLocalStorage.setItem(SYNC_SCROLL_STATUS, "invalid"); + + const { result } = renderHook(() => useInitSyncScrollStatus()); + expect(result.current.isSyncScroll).toBe(true); // 非 false 字符串视为 true + }); +}); diff --git a/packages/mini-markdown-editor/src/hooks/__test__/use-persist-editor-content.test.ts b/packages/mini-markdown-editor/src/hooks/__test__/use-persist-editor-content.test.ts new file mode 100644 index 0000000..6ab2263 --- /dev/null +++ b/packages/mini-markdown-editor/src/hooks/__test__/use-persist-editor-content.test.ts @@ -0,0 +1,121 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { vi, describe, test, expect, beforeEach, afterEach } from "vitest"; +import { usePersistEditorContent } from "../use-persist-editor-content"; +import { EDITOR_CONTENT_KEY } from "@/common"; +import { useGlobalConfig } from "../use-global-config"; +// 模拟依赖项 +vi.mock("@/utils/storage", () => ({ + safeLocalStorage: () => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }), +})); + +vi.mock("../use-global-config", () => ({ + useGlobalConfig: vi.fn(() => ({ + local: true, + value: "global-value", + })), +})); + +vi.mock("ahooks", () => ({ + useDebounceFn: (fn: any, options: any) => ({ + run: vi.fn((...args) => { + setTimeout(() => fn(...args), options.wait); + }), + }), +})); + +describe("usePersistEditorContent Hook测试", () => { + const mockLocalStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + + // 重置模拟实现 + mockLocalStorage.getItem.mockImplementation(() => null); + vi.mocked(useGlobalConfig).mockImplementation(() => ({ + local: true, + value: "global-value", + })); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("当 local=true 时应保存到 localStorage", async () => { + const { result } = renderHook(() => usePersistEditorContent()); + + act(() => { + result.current.saveContent("test-content"); + vi.advanceTimersByTime(300); + }); + waitFor(() => { + expect(mockLocalStorage.setItem).toHaveBeenCalledWith(EDITOR_CONTENT_KEY, "test-content"); + }); + }); + + test("当 local=false 时不应保存到 localStorage", () => { + vi.mocked(useGlobalConfig).mockReturnValue({ local: false, value: "" }); + const { result } = renderHook(() => usePersistEditorContent()); + + act(() => { + result.current.saveContent("test-content"); + vi.advanceTimersByTime(300); + }); + waitFor(() => { + expect(mockLocalStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + test("防抖功能应正确生效", () => { + const { result } = renderHook(() => usePersistEditorContent()); + + act(() => { + result.current.saveContent("first"); + result.current.saveContent("second"); + vi.advanceTimersByTime(299); + }); + + expect(mockLocalStorage.setItem).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); // 累计 300ms + }); + waitFor(() => { + expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1); + expect(mockLocalStorage.setItem).toHaveBeenCalledWith(EDITOR_CONTENT_KEY, "second"); + }); + }); + + test("当 local=false 且存在 value 时应返回全局值", () => { + vi.mocked(useGlobalConfig).mockReturnValue({ + local: false, + value: "custom-value", + }); + + const { result } = renderHook(() => usePersistEditorContent()); + expect(result.current.getContent()).toBe("custom-value"); + }); + + test("当 local=true 时应返回 localStorage 值", () => { + mockLocalStorage.getItem.mockReturnValue("stored-content"); + const { result } = renderHook(() => usePersistEditorContent()); + waitFor(() => { + expect(result.current.getContent()).toBe("stored-content"); + }); + }); + + test("当存储为空时应返回空字符串", () => { + mockLocalStorage.getItem.mockReturnValue(null); + const { result } = renderHook(() => usePersistEditorContent()); + expect(result.current.getContent()).toBe(""); + }); +}); diff --git a/packages/mini-markdown-editor/src/hooks/__test__/use-preview-theme.test.ts b/packages/mini-markdown-editor/src/hooks/__test__/use-preview-theme.test.ts new file mode 100644 index 0000000..46262bd --- /dev/null +++ b/packages/mini-markdown-editor/src/hooks/__test__/use-preview-theme.test.ts @@ -0,0 +1,80 @@ +import { renderHook } from "@testing-library/react"; +import { vi, describe, test, expect, beforeEach, afterEach } from "vitest"; +import { usePreviewTheme } from "../use-preview-theme"; + +// 模拟主题配置 +const mockPreviewTheme = { + light: { color: "#ffffff", bg: "#000000" }, + dark: { color: "#000000", bg: "#ffffff" }, +}; + +// 模拟主题模块 +vi.mock("@/theme/preview-theme", () => ({ + previewTheme: { + light: { color: "#ffffff", bg: "#000000" }, + dark: { color: "#000000", bg: "#ffffff" }, + }, +})); + +describe("usePreviewTheme Hook测试", () => { + let setPropertySpy: any; + + beforeEach(() => { + // 重置 DOM 操作 spy + setPropertySpy = vi.spyOn(document.body.style, "setProperty"); + document.body.style.cssText = ""; // 清空样式 + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("应正确应用 light 主题变量", () => { + renderHook(() => usePreviewTheme("light")); + + expect(setPropertySpy).toHaveBeenCalledWith("--md-preview-color", mockPreviewTheme.light.color); + expect(setPropertySpy).toHaveBeenCalledWith("--md-preview-bg", mockPreviewTheme.light.bg); + }); + + test("应正确应用 dark 主题变量", () => { + renderHook(() => usePreviewTheme("dark")); + + expect(setPropertySpy).toHaveBeenCalledWith("--md-preview-color", mockPreviewTheme.dark.color); + expect(setPropertySpy).toHaveBeenCalledWith("--md-preview-bg", mockPreviewTheme.dark.bg); + }); + + test("当主题切换时应更新变量", async () => { + const { rerender } = renderHook( + ({ theme }: { theme: "light" | "dark" }) => usePreviewTheme(theme), + { initialProps: { theme: "light" } }, + ); + + // 初始应用 light 主题 + expect(setPropertySpy).toHaveBeenCalledTimes(2); + + setPropertySpy.mockClear(); + + // 切换为 dark 主题 + rerender({ theme: "dark" }); + + expect(setPropertySpy).toHaveBeenCalledWith("--md-preview-color", mockPreviewTheme.dark.color); + expect(setPropertySpy).toHaveBeenCalledWith("--md-preview-bg", mockPreviewTheme.dark.bg); + }); + + test("应清理旧主题变量", () => { + const removePropertySpy = vi.spyOn(document.body.style, "removeProperty"); + + const { rerender } = renderHook( + ({ theme }: { theme: "light" | "dark" }) => usePreviewTheme(theme), + { initialProps: { theme: "light" } }, + ); + + // 切换主题前设置旧变量 + document.body.style.setProperty("--md-preview-old", "value"); + + rerender({ theme: "dark" }); + + // 验证没有清理操作(根据当前实现逻辑) + expect(removePropertySpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mini-markdown-editor/src/hooks/__test__/use-save-content.test.ts b/packages/mini-markdown-editor/src/hooks/__test__/use-save-content.test.ts new file mode 100644 index 0000000..064d8b9 --- /dev/null +++ b/packages/mini-markdown-editor/src/hooks/__test__/use-save-content.test.ts @@ -0,0 +1,82 @@ +import { renderHook, act } from "@testing-library/react"; +import { vi, describe, test, expect, beforeEach, afterEach } from "vitest"; +import { useSaveContent } from "../use-save-content"; +import { EDITOR_CONTENT_KEY } from "@/common"; + +// 模拟 localStorage +const mockLocalStorage = { + setItem: vi.fn(), +}; + +// 模拟依赖项 +vi.mock("@/utils/storage", () => ({ + safeLocalStorage: () => mockLocalStorage, +})); + +describe("useSaveContent Hook测试", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockLocalStorage.setItem.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("应在防抖延迟后保存内容", () => { + const { result } = renderHook(() => useSaveContent()); + + act(() => { + result.current("test-content"); + vi.advanceTimersByTime(299); + }); + + expect(mockLocalStorage.setItem).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith(EDITOR_CONTENT_KEY, "test-content"); + }); + + test("多次调用应只执行最后一次保存", () => { + const { result } = renderHook(() => useSaveContent()); + + act(() => { + result.current("content-1"); + result.current("content-2"); + result.current("final-content"); + vi.advanceTimersByTime(300); + }); + + expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1); + expect(mockLocalStorage.setItem).toHaveBeenCalledWith(EDITOR_CONTENT_KEY, "final-content"); + }); + + test("应正确处理空内容", () => { + const { result } = renderHook(() => useSaveContent()); + + act(() => { + result.current(""); + vi.advanceTimersByTime(300); + }); + + expect(mockLocalStorage.setItem).toHaveBeenCalledWith(EDITOR_CONTENT_KEY, ""); + }); + + test("应使用正确的防抖时间", () => { + const { result } = renderHook(() => useSaveContent()); + + act(() => { + result.current("test-timing"); + vi.advanceTimersByTime(299); + }); + expect(mockLocalStorage.setItem).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(mockLocalStorage.setItem).toHaveBeenCalled(); + }); +}); diff --git a/packages/mini-markdown-editor/src/hooks/__test__/use-sync-editorview.test.ts b/packages/mini-markdown-editor/src/hooks/__test__/use-sync-editorview.test.ts new file mode 100644 index 0000000..36bc18b --- /dev/null +++ b/packages/mini-markdown-editor/src/hooks/__test__/use-sync-editorview.test.ts @@ -0,0 +1,73 @@ +import { renderHook } from "@testing-library/react"; +import { vi, describe, test, expect, beforeEach } from "vitest"; +import { useSyncEditorView } from "../use-sync-editorview"; +import { useEditorContentStore } from "@/store/editor"; +import { insertContent } from "@/utils/insert-content"; + +// 模拟依赖模块 +vi.mock("@/store/editor", () => ({ + useEditorContentStore: vi.fn(), +})); + +vi.mock("@/utils/insert-content", () => ({ + insertContent: { + setEditorView: vi.fn(), + }, +})); + +describe("useSyncEditorView Hook测试", () => { + const mockEditorView = { id: "editor-1" }; + const mockSetEditorView = vi.mocked(insertContent.setEditorView); + + beforeEach(() => { + vi.clearAllMocks(); + // 默认返回有效 editorView + vi.mocked(useEditorContentStore).mockImplementation((selector: any) => + selector({ editorView: mockEditorView }), + ); + }); + + test("应在挂载时同步 editorView 实例", () => { + renderHook(() => useSyncEditorView()); + + expect(mockSetEditorView).toHaveBeenCalledTimes(1); + expect(mockSetEditorView).toHaveBeenCalledWith(mockEditorView); + }); + + test("当 editorView 变化时应重新同步", () => { + const { rerender } = renderHook(() => useSyncEditorView()); + + // 初始调用 + expect(mockSetEditorView).toHaveBeenCalledTimes(1); + + // 更新 editorView + const newEditorView = { id: "editor-2" }; + vi.mocked(useEditorContentStore).mockImplementation((selector: any) => + selector({ editorView: newEditorView }), + ); + rerender(); + + expect(mockSetEditorView).toHaveBeenCalledTimes(2); + expect(mockSetEditorView).toHaveBeenCalledWith(newEditorView); + }); + + test("当 editorView 为 null 时应安全处理", () => { + vi.mocked(useEditorContentStore).mockImplementation((selector: any) => + selector({ editorView: null }), + ); + + renderHook(() => useSyncEditorView()); + + expect(mockSetEditorView).toHaveBeenCalledWith(null); + }); + + test("应在每次渲染时同步(无依赖数组)", () => { + const { rerender } = renderHook(() => useSyncEditorView()); + + rerender(); // 强制重新渲染 + rerender(); // 再次重新渲染 + + // 预期每次渲染都会触发同步(3次初始渲染+2次重渲染) + expect(mockSetEditorView).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/mini-markdown-editor/src/hooks/__test__/use-toolbar.test.tsx b/packages/mini-markdown-editor/src/hooks/__test__/use-toolbar.test.tsx new file mode 100644 index 0000000..2f89aef --- /dev/null +++ b/packages/mini-markdown-editor/src/hooks/__test__/use-toolbar.test.tsx @@ -0,0 +1,62 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, test, expect, vi } from "vitest"; +import { useToolbar } from "../use-toolbar"; +import { type ReactNode } from "react"; +import { ToolbarContext } from "@/components/providers/toolbar-provider"; +import { ToolbarContextValues } from "@/types/toolbar"; + +// 测试用 Provider 组件 +const MockProvider = ({ + children, + value, +}: { + children: ReactNode; + value: ToolbarContextValues; +}) => {children}; + +describe("useToolbar Hook测试", () => { + test("当存在 ToolbarProvider 时返回有效上下文", () => { + const expectedValue: ToolbarContextValues = { toolbars: [{ type: "file" }] }; + + const { result } = renderHook(() => useToolbar(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).toEqual(expectedValue); + }); + + test("当缺少 ToolbarProvider 时抛出指定错误", () => { + // 禁用 React 的错误日志避免测试输出混乱 + const consoleError = vi.spyOn(console, "error").mockImplementation(() => null); + + expect(() => { + renderHook(() => useToolbar()); // 不包裹 Provider + }).toThrowError("Toolbar init error"); + + consoleError.mockRestore(); + }); + + test("应响应上下文值的更新", () => { + const initialValue: ToolbarContextValues = { toolbars: [{ type: "file" }] }; + const updatedValue: ToolbarContextValues = { toolbars: [{ type: "file" }, { type: "edit" }] }; + + const { result, rerender } = renderHook(() => useToolbar(), { + wrapper: ({ children }) => {children}, + }); + + // 验证初始值 + expect(result.current).toEqual(initialValue); + + // 更新 Provider 值并重新渲染 + rerender({ + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + + waitFor(() => { + // 验证更新后的值 + expect(result.current).toEqual(updatedValue); + }); + }); +}); diff --git a/packages/mini-markdown-editor/src/hooks/use-expose-handle.ts b/packages/mini-markdown-editor/src/hooks/use-expose-handle.ts index edee6c1..a292c63 100644 --- a/packages/mini-markdown-editor/src/hooks/use-expose-handle.ts +++ b/packages/mini-markdown-editor/src/hooks/use-expose-handle.ts @@ -40,8 +40,7 @@ class ExposeHandle { public setCursor(start: number, end: number) { if (!this.view) return; if (start < end) { - new Error("start 必须比 end 大"); - return; + throw new Error("start 必须比 end 大"); } // 获取光标位置 const { from, to } = this.view.state.selection.ranges[0]; -- Gitee From 4ce011bbb592a2c7d42a7e6e8ac5907ca12558fd Mon Sep 17 00:00:00 2001 From: link <3399652842@qq.com> Date: Fri, 21 Feb 2025 03:16:59 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(mini-markdown-editor):=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90utils=E7=9A=84unit=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/filter-context-props.test.ts | 91 ++++++++++++++ .../utils/__test__/format-contents.test.ts | 107 ++++++++++++++++ .../src/utils/__test__/handle-hotkeys.test.ts | 119 ++++++++++++++++++ .../src/utils/__test__/handle-scroll.test.ts | 66 ++++++++++ .../src/utils/__test__/insert-content.test.ts | 108 ++++++++++++++++ .../src/utils/__test__/output-html.test.ts | 86 +++++++++++++ .../src/utils/__test__/output-pdf.test.ts | 80 ++++++++++++ .../utils/__test__/set-global-config.test.ts | 76 +++++++++++ .../src/utils/__test__/storage.test.ts | 108 ++++++++++++++++ .../src/utils/handle-scroll.ts | 4 +- 10 files changed, 843 insertions(+), 2 deletions(-) create mode 100644 packages/mini-markdown-editor/src/utils/__test__/filter-context-props.test.ts create mode 100644 packages/mini-markdown-editor/src/utils/__test__/format-contents.test.ts create mode 100644 packages/mini-markdown-editor/src/utils/__test__/handle-hotkeys.test.ts create mode 100644 packages/mini-markdown-editor/src/utils/__test__/handle-scroll.test.ts create mode 100644 packages/mini-markdown-editor/src/utils/__test__/insert-content.test.ts create mode 100644 packages/mini-markdown-editor/src/utils/__test__/output-html.test.ts create mode 100644 packages/mini-markdown-editor/src/utils/__test__/output-pdf.test.ts create mode 100644 packages/mini-markdown-editor/src/utils/__test__/set-global-config.test.ts create mode 100644 packages/mini-markdown-editor/src/utils/__test__/storage.test.ts diff --git a/packages/mini-markdown-editor/src/utils/__test__/filter-context-props.test.ts b/packages/mini-markdown-editor/src/utils/__test__/filter-context-props.test.ts new file mode 100644 index 0000000..8081f3d --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/__test__/filter-context-props.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect, vi } from "vitest"; +import { filterContextProps } from "../filter-context-props"; +import type { GlobalConfig } from "@/types/global-config"; + +// 模拟默认配置 +vi.mock("@/config/global", () => ({ + defaultGlobalConfig: { + theme: "light", + language: "en", + fontSize: 14, + }, +})); + +describe("filterContextProps Utils测试", () => { + // 基础功能测试 + test("应过滤上下文属性并保留 className/style", () => { + const inputProps = { + theme: "dark", // 在 defaultGlobalConfig 中的上下文属性 + isSyncScroll: true, // 显式添加的上下文属性 + value: "test", // 显式添加的上下文属性 + className: "editor", + style: { padding: 8 }, + customProp: "保留我", // 应保留的普通属性 + } as unknown as GlobalConfig; + + const result = filterContextProps(inputProps); + + expect(result).toEqual({ + className: "editor", + style: { padding: 8 }, + customProp: "保留我", + }); + }); + + // 边界情况测试 + test("处理空 props 时应返回空对象", () => { + const result = filterContextProps({} as GlobalConfig); + expect(result).toEqual({ + className: undefined, + style: undefined, + }); + }); + + // 特殊属性保留测试 + test("应始终保留 className 和 style", () => { + const inputProps = { + className: "special", + style: { margin: 0 }, + theme: "light", // 上下文属性 + } as GlobalConfig; + + const result = filterContextProps(inputProps); + expect(result).toEqual({ + className: "special", + style: { margin: 0 }, + }); + }); + + // 动态添加键测试 + test("应过滤显式添加的 isSyncScroll 和 value", () => { + const inputProps = { + isSyncScroll: false, + value: "content", + customProp: "保留我", // 应保留的普通属性 + } as unknown as GlobalConfig; + + const result = filterContextProps(inputProps); + expect(result).toEqual({ + className: undefined, + style: undefined, + customProp: "保留我", // 应保留的普通属性 + }); + }); + + // 非上下文属性保留测试 + test("应保留非上下文属性", () => { + const inputProps = { + readOnly: true, + placeholder: "输入内容...", + className: "form-control", + } as GlobalConfig; + + const result = filterContextProps(inputProps); + expect(result).toEqual({ + className: "form-control", + style: undefined, + readOnly: true, + placeholder: "输入内容...", + }); + }); +}); diff --git a/packages/mini-markdown-editor/src/utils/__test__/format-contents.test.ts b/packages/mini-markdown-editor/src/utils/__test__/format-contents.test.ts new file mode 100644 index 0000000..d7a41cc --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/__test__/format-contents.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect } from "vitest"; +import { formatContents } from "../format-contents"; + +// 创建带层级的 DOM 元素集合 +function createTestElements(elements: Array<{ tag: string; line: string; text: string }>) { + const container = document.createElement("div"); + elements.forEach(({ tag, line, text }) => { + const el = document.createElement(tag); + el.setAttribute("data-line", line); + el.textContent = text; + container.appendChild(el); + }); + return container.querySelectorAll("h1, h2, h3, h4, h5, h6"); +} + +describe("formatContents Utils测试", () => { + // 基础功能测试 + test("应正确转换单层标题结构", () => { + const nodeList = createTestElements([ + { tag: "h2", line: "1", text: "标题1" }, + { tag: "h2", line: "2", text: "标题2" }, + ]); + + const result = formatContents(nodeList); + expect(result).toEqual([ + expect.objectContaining({ key: "1", nodeName: "H2", children: [] }), + expect.objectContaining({ key: "2", nodeName: "H2", children: [] }), + ]); + }); + + // 嵌套结构测试 + test("应正确生成层级嵌套结构", () => { + const nodeList = createTestElements([ + { tag: "h2", line: "1", text: "父标题" }, + { tag: "h3", line: "2", text: "子标题1" }, + { tag: "h3", line: "3", text: "子标题2" }, + ]); + + const result = formatContents(nodeList); + expect(result).toEqual([ + expect.objectContaining({ + key: "1", + children: [expect.objectContaining({ key: "2" }), expect.objectContaining({ key: "3" })], + }), + ]); + }); + + // 复杂层级测试 + test("应处理多级嵌套和层级回退", () => { + const nodeList = createTestElements([ + { tag: "h2", line: "1", text: "H2-1" }, + { tag: "h3", line: "2", text: "H3-1" }, + { tag: "h4", line: "3", text: "H4-1" }, + { tag: "h2", line: "4", text: "H2-2" }, + { tag: "h3", line: "5", text: "H3-2" }, + ]); + + const result = formatContents(nodeList); + expect(result).toEqual([ + expect.objectContaining({ + key: "1", + children: [ + expect.objectContaining({ + key: "2", + children: [expect.objectContaining({ key: "3" })], + }), + ], + }), + expect.objectContaining({ + key: "4", + children: [expect.objectContaining({ key: "5" })], + }), + ]); + }); + + // 边界条件测试 + test("处理空输入应返回空数组", () => { + const emptyList = createTestElements([]); + expect(formatContents(emptyList)).toEqual([]); + }); + + // 混合层级顺序测试 + test("应正确处理层级升降序列", () => { + const nodeList = createTestElements([ + { tag: "h2", line: "1", text: "A" }, + { tag: "h3", line: "2", text: "B" }, + { tag: "h4", line: "3", text: "C" }, + { tag: "h3", line: "4", text: "D" }, + { tag: "h2", line: "5", text: "E" }, + ]); + + const result = formatContents(nodeList); + expect(result).toEqual([ + expect.objectContaining({ + key: "1", + children: [ + expect.objectContaining({ + key: "2", + children: [expect.objectContaining({ key: "3" })], + }), + expect.objectContaining({ key: "4" }), + ], + }), + expect.objectContaining({ key: "5" }), + ]); + }); +}); diff --git a/packages/mini-markdown-editor/src/utils/__test__/handle-hotkeys.test.ts b/packages/mini-markdown-editor/src/utils/__test__/handle-hotkeys.test.ts new file mode 100644 index 0000000..8cf53b0 --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/__test__/handle-hotkeys.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { + createInsertTextHandler, + createFullScreenHandler, + createSaveHandler, + createCustomHandler, + handleHotkeys, +} from "../handle-hotkeys"; +import { BaseToolbarType } from "@/types/toolbar"; +import { InsertTextEvent } from "@/config/toolbar/event"; +import { useToolbarStore } from "@/store/toolbar"; +import { ToolbarItem } from "@/types/toolbar"; +import { global } from "../set-global-config"; +// 模拟 Zustand store +vi.mock("@/store/toolbar", () => ({ + useToolbarStore: { + getState: vi.fn(() => ({ isFullScreen: false })), + setState: vi.fn(), + }, +})); + +// 模拟全局对象 +vi.mock("../set-global-config", () => ({ + global: { + saveHotKeyHandle: vi.fn(), + }, +})); + +// 模拟事件触发 +vi.mock("@/config/toolbar/event", () => ({ + InsertTextEvent: vi.fn(), +})); + +describe("handle-hotkeys Utils测试", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("createInsertTextHandler - 使用自定义处理方法", () => { + const mockHandle = vi.fn(); + const handler = createInsertTextHandler({ + command: "a", + description: "插入文本", + handle: mockHandle, + }); + + expect(handler.run()).toBe(true); + expect(mockHandle).toHaveBeenCalled(); + expect(InsertTextEvent).not.toHaveBeenCalled(); + }); + + test("createInsertTextHandler - 触发默认事件", () => { + const handler = createInsertTextHandler({ + command: "b", + description: "插入标题", + }); + + handler.run(); + expect(InsertTextEvent).toHaveBeenCalledWith("插入标题"); + }); + + test("createFullScreenHandler - 切换全屏状态", () => { + const handler = createFullScreenHandler({ + command: "f11", + description: "全屏", + }); + + handler.run(); + expect(useToolbarStore.setState).toHaveBeenCalledWith({ + isFullScreen: true, + }); + }); + + test("createSaveHandler - 触发保存操作", () => { + const handler = createSaveHandler({ + command: "ctrl+s", + description: "保存", + }); + + handler.run(); + expect(global.saveHotKeyHandle).toHaveBeenCalled(); + }); + + test("createCustomHandler - 调用自定义处理", () => { + const mockHandle = vi.fn(); + const handler = createCustomHandler({ + command: "c", + description: "自定义", + handle: mockHandle, + }); + + expect(handler.run()).toBe(true); + expect(mockHandle).toHaveBeenCalled(); + }); + + const mockToolbar: ToolbarItem[] = [ + { + type: BaseToolbarType.FULLSCREEN, + hotkey: { command: "f", description: "全屏" }, + }, + { + type: "custom-type", + hotkey: { command: "c", description: "自定义" }, + list: [ + { + title: "子项", + type: "sub-item", + hotkey: { command: "s", description: "子项" }, + }, + ], + }, + ]; + + test("handleHotkeys - 生成完整热键映射", () => { + const handlers = handleHotkeys(mockToolbar); + expect(Object.keys(handlers)).toEqual(["f", "c", "s"]); + expect(handlers.f.run()).toBe(true); + }); +}); diff --git a/packages/mini-markdown-editor/src/utils/__test__/handle-scroll.test.ts b/packages/mini-markdown-editor/src/utils/__test__/handle-scroll.test.ts new file mode 100644 index 0000000..b551cac --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/__test__/handle-scroll.test.ts @@ -0,0 +1,66 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { + handleEditorScroll, + handlePreviewScroll, + handleScrollTop, + scrollSynchronizer, +} from "../handle-scroll"; +import type { EditorView } from "@codemirror/view"; + +// 模拟 DOM 环境 +const mockPreviewView = document.createElement("div"); +mockPreviewView.scrollTo = vi.fn(); +const mockEditorView = { + scrollDOM: document.createElement("div"), + state: { doc: { lines: 100 } }, +} as unknown as EditorView; +mockEditorView.scrollDOM.scrollTo = vi.fn(); + +describe("handle-scroll Utils测试", () => { + beforeEach(() => { + // 重置所有模拟调用 + vi.restoreAllMocks(); + }); + + // 测试 handleEditorScroll + describe("handleEditorScroll", () => { + test("正常调用时应触发同步逻辑", () => { + const spy = vi.spyOn(scrollSynchronizer, "handleEditorScroll"); + + handleEditorScroll({ + editorView: mockEditorView, + previewView: mockPreviewView, + }); + + expect(spy).toHaveBeenCalledWith(mockEditorView, mockPreviewView); + }); + }); + + // 测试 handlePreviewScroll + describe("handlePreviewScroll", () => { + test("正常调用时应触发同步逻辑", () => { + const spy = vi.spyOn(scrollSynchronizer, "handlePreviewScroll"); + + handlePreviewScroll({ + editorView: mockEditorView, + previewView: mockPreviewView, + }); + + expect(spy).toHaveBeenCalledWith(mockPreviewView, mockEditorView); + }); + }); + + // 测试 handleScrollTop + describe("handleScrollTop", () => { + test("正常调用时应触发置顶逻辑", () => { + const spy = vi.spyOn(scrollSynchronizer, "handleScrollTop"); + + handleScrollTop({ + editorView: mockEditorView, + previewView: mockPreviewView, + }); + + expect(spy).toHaveBeenCalledWith(mockEditorView, mockPreviewView); + }); + }); +}); diff --git a/packages/mini-markdown-editor/src/utils/__test__/insert-content.test.ts b/packages/mini-markdown-editor/src/utils/__test__/insert-content.test.ts new file mode 100644 index 0000000..0029018 --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/__test__/insert-content.test.ts @@ -0,0 +1,108 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { EditorView } from "@codemirror/view"; +import { insertContent } from "../insert-content"; + +// 模拟 CodeMirror 的 EditorView +class MockEditorView { + state = { + selection: { + ranges: [{ from: 0, to: 0 }], + }, + sliceDoc: vi.fn(() => "selected text"), //始终返回长度为13的字符串 + doc: { toString: () => "" }, + }; + + focus = vi.fn(); + dispatch = vi.fn(); +} + +// 模拟 undo/redo 命令 +vi.mock("@codemirror/commands", () => ({ + undo: vi.fn(), + redo: vi.fn(), +})); + +describe("InsertContent Utils测试", () => { + let mockView: any; + + beforeEach(() => { + mockView = new MockEditorView(); + insertContent.setEditorView(mockView as unknown as EditorView); + }); + + test("设置编辑器视图", () => { + insertContent.setEditorView(null); + expect(insertContent["editorView"]).toBeNull(); + }); + + describe("插入内容", () => { + test("无选中文本时插入内容", () => { + mockView.state.selection.ranges[0] = { from: 0, to: 0 }; + + insertContent.insertContent("new content", { anchor: 0, head: 0 }); + + expect(mockView.dispatch).toHaveBeenCalledWith({ + changes: { from: 0, to: 0, insert: "new content" }, + selection: { anchor: 0, head: 0 }, + }); + }); + + test("替换选中文本并调整光标", () => { + mockView.state.selection.ranges[0] = { from: 5, to: 10 }; + + insertContent.insertContent("**", { anchor: 2, head: 2 }); + + expect(mockView.state.sliceDoc).toHaveBeenCalledWith(5, 10); + expect(mockView.dispatch).toHaveBeenCalledWith({ + changes: { + from: 5, + to: 10, + insert: "**selected text", + }, + selection: { + anchor: 7, // 5 + 2 + head: 20, // 5+2+13 + }, + }); + }); + }); + + describe("光标处插入文本", () => { + test("在当前位置插入文本", () => { + mockView.state.selection.ranges[0] = { from: 3, to: 3 }; + + insertContent.insertTextAtCursor("hello"); + + expect(mockView.dispatch).toHaveBeenCalledWith({ + changes: { from: 3, to: 3, insert: "hello" }, + selection: { anchor: 8, head: 8 }, // 3 + 5 + }); + }); + }); + + describe("撤销/重做", () => { + test("触发撤销命令", async () => { + const { undo } = await import("@codemirror/commands"); + insertContent.undo(); + expect(undo).toHaveBeenCalledWith(mockView); + }); + + test("触发重做命令", async () => { + const { redo } = await import("@codemirror/commands"); + insertContent.redo(); + expect(redo).toHaveBeenCalledWith(mockView); + }); + + test("无编辑器视图时安全处理", async () => { + insertContent.setEditorView(null); + expect(() => insertContent.undo()).not.toThrow(); + expect(() => insertContent.redo()).not.toThrow(); + }); + }); + + test("未设置编辑器视图时安全处理", () => { + insertContent.setEditorView(null); + expect(() => insertContent.insertContent("test", { anchor: 0, head: 0 })).not.toThrow(); + expect(() => insertContent.insertTextAtCursor("test")).not.toThrow(); + }); +}); diff --git a/packages/mini-markdown-editor/src/utils/__test__/output-html.test.ts b/packages/mini-markdown-editor/src/utils/__test__/output-html.test.ts new file mode 100644 index 0000000..00117da --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/__test__/output-html.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { exportHTML } from "../output-html"; + +// 模拟浏览器环境 +const mockClick = vi.fn(); +const mockDocument = { + styleSheets: [] as any[], + body: { + appendChild: vi.fn(), + removeChild: vi.fn(), + }, + createElement: vi.fn(() => ({ + click: mockClick, + href: "blob:fake-url", + download: "test.html", + })), +}; + +// 模拟全局对象 +(global as any).document = mockDocument; +(global as any).URL = { + createObjectURL: vi.fn(() => "blob:fake-url"), + revokeObjectURL: vi.fn(), +}; +(global as any).Blob = vi.fn(); + +describe("exportHTML Utils测试", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // 重置模拟样式表 + mockDocument.styleSheets = [ + { + cssRules: [ + { cssText: "body { color: red; }" }, + { cssText: ".container { padding: 20px; }" }, + ], + }, + { + cssRules: [], // 模拟空样式表 + }, + ]; + }); + test("应生成包含正确内容的HTML", async () => { + // 创建测试元素 + const testElement = document.createElement("div"); + testElement.outerHTML = "

Test Content

"; + + await exportHTML(testElement, "test-file"); + + // 验证Blob内容 + const [blobParts] = (Blob as any).mock.calls[0]; + const htmlContent = blobParts[0]; + + expect(htmlContent).toContain("

Test Content

"); + expect(htmlContent).toContain("body { color: red; }"); + expect(htmlContent).toContain(".container { padding: 20px; }"); + }); + + test("应正确处理样式表访问错误", async () => { + // 模拟样式表访问错误 + mockDocument.styleSheets[0].cssRules = null; + const consoleSpy = vi.spyOn(console, "error"); + + const testElement = document.createElement("div"); + await exportHTML(testElement, "test"); + expect(consoleSpy).toHaveBeenCalledWith("Error accessing stylesheet:", expect.any(Error)); + }); + + test("应创建正确的下载链接", async () => { + const testElement = document.createElement("div"); + await exportHTML(testElement, "download-test"); + + // 验证下载链接创建 + expect(document.createElement).toHaveBeenCalledWith("a"); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake-url"); + }); + + test("应清理创建的DOM元素", async () => { + const testElement = document.createElement("div"); + await exportHTML(testElement, "test"); + + expect(mockDocument.body.appendChild).toHaveBeenCalled(); + expect(mockDocument.body.removeChild).toHaveBeenCalledWith(mockDocument.createElement()); + }); +}); diff --git a/packages/mini-markdown-editor/src/utils/__test__/output-pdf.test.ts b/packages/mini-markdown-editor/src/utils/__test__/output-pdf.test.ts new file mode 100644 index 0000000..af4210b --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/__test__/output-pdf.test.ts @@ -0,0 +1,80 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { exportPdf } from "../output-pdf"; +import html2pdf from "html2pdf.js"; + +// 模拟 html2pdf 库 +vi.mock("html2pdf.js", () => { + const instance = { + from: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + save: vi.fn(), + }; + return { + default: vi.fn(() => instance), + }; +}); + +// 模拟 window 对象 +const mockWindow = { + scrollX: 100, + scrollY: 200, +}; +(global as any).window = mockWindow; + +describe("exportPdf Utils测试", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("应正确调用 html2pdf 方法链", async () => { + const mockElement = document.createElement("div"); + await exportPdf(mockElement, "test-file"); + + // 验证方法调用链 + expect(html2pdf).toHaveBeenCalled(); + expect(html2pdf().from).toHaveBeenCalledWith(mockElement); + expect(html2pdf().set).toHaveBeenCalledWith(expect.any(Object)); + expect(html2pdf().save).toHaveBeenCalled(); + }); + + test("应包含正确的配置选项", async () => { + const mockElement = document.createElement("div"); + await exportPdf(mockElement, "test-file"); + + // 验证配置参数 + const expectedOptions = { + margin: 10, + filename: "test-file", + image: { type: "jpeg", quality: 0.98 }, + html2canvas: { + scale: 2, + useCORS: true, + allowTaint: true, + scrollX: -100, // 来自 window.scrollX + scrollY: -200, // 来自 window.scrollY + windowWidth: 2100, + windowHeight: 2970, + includeShadowDom: true, + }, + jsPDF: { unit: "mm", format: "a4", orientation: "portrait" }, + }; + + expect(html2pdf().set).toHaveBeenCalledWith(expectedOptions); + }); + + test("Promise 应始终 resolve", async () => { + const mockElement = document.createElement("div"); + const result = await exportPdf(mockElement, "test-file"); + expect(result).toEqual({}); + }); + + test("应处理空元素输入", async () => { + const consoleSpy = vi.spyOn(console, "error"); + // 测试 null 元素 + await exportPdf(null as any, "test-file"); + + // 验证基础调用仍然执行 + expect(html2pdf().from).toHaveBeenCalledWith(null); + expect(consoleSpy).not.toHaveBeenCalled(); // 根据当前实现不会报错 + }); +}); diff --git a/packages/mini-markdown-editor/src/utils/__test__/set-global-config.test.ts b/packages/mini-markdown-editor/src/utils/__test__/set-global-config.test.ts new file mode 100644 index 0000000..cc45170 --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/__test__/set-global-config.test.ts @@ -0,0 +1,76 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { global } from "../set-global-config"; +import type { EditorView } from "@uiw/react-codemirror"; +import type { GlobalConfig } from "@/types/global-config"; + +// 模拟 EditorView 实例 +const mockEditorView = { + state: { + doc: { + toString: vi.fn(() => "test content"), + }, + }, +} as unknown as EditorView; + +describe("set-global-config Utils测试", () => { + beforeEach(() => { + // 重置单例状态 + global["config"] = {}; + global["view"] = null; + }); + + describe("setGlobalConfig", () => { + test("应正确设置配置和视图", () => { + const config: GlobalConfig = { theme: "dark" }; + + global.setGlobalConfig(config, mockEditorView); + + expect(global["config"]).toEqual(config); + expect(global["view"]).toBe(mockEditorView); + }); + + test("应覆盖现有配置", () => { + global.setGlobalConfig({ theme: "light" }, mockEditorView); + global.setGlobalConfig({ local: false }, mockEditorView); + + expect(global["config"]).toEqual({ local: false }); + }); + }); + + describe("saveHotKeyHandle", () => { + test("无视图时应跳过处理", () => { + const consoleSpy = vi.spyOn(console, "error"); + + global.saveHotKeyHandle(); + + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + test("有视图但无内容时应跳过回调", () => { + const mockEmptyView = { + state: { doc: { toString: () => "" } }, + } as EditorView; + const onSave = vi.fn(); + + global.setGlobalConfig({ onSave }, mockEmptyView); + global.saveHotKeyHandle(); + + expect(onSave).not.toHaveBeenCalled(); + }); + + test("有内容时应触发 onSave 回调", () => { + const onSave = vi.fn(); + + global.setGlobalConfig({ onSave }, mockEditorView); + global.saveHotKeyHandle(); + + expect(onSave).toHaveBeenCalledWith("test content", mockEditorView); + }); + + test("应正确处理未定义的回调", () => { + global.setGlobalConfig({}, mockEditorView); + + expect(() => global.saveHotKeyHandle()).not.toThrow(); + }); + }); +}); diff --git a/packages/mini-markdown-editor/src/utils/__test__/storage.test.ts b/packages/mini-markdown-editor/src/utils/__test__/storage.test.ts new file mode 100644 index 0000000..3833a93 --- /dev/null +++ b/packages/mini-markdown-editor/src/utils/__test__/storage.test.ts @@ -0,0 +1,108 @@ +import { describe, test, expect, vi, beforeEach, afterAll } from "vitest"; +import { safeLocalStorage } from "../storage"; + +// 模拟 localStorage +const mockLocalStorage = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + get length() { + return Object.keys(store).length; + }, + key: (index: number) => Object.keys(store)[index] || null, + }; +})(); + +describe("safeLocalStorage Utils测试", () => { + let originalLocalStorage: Storage; + const consoleError = vi.spyOn(console, "error"); + + beforeEach(() => { + // 重置模拟 localStorage + mockLocalStorage.clear(); + vi.clearAllMocks(); + + // 保存原始 localStorage 引用 + originalLocalStorage = window.localStorage; + (window as any).localStorage = mockLocalStorage; + }); + + afterAll(() => { + // 恢复原始 localStorage + (window as any).localStorage = originalLocalStorage; + }); + + describe("正常环境测试", () => { + test("存储并获取字符串值", () => { + const storage = safeLocalStorage(); + storage.setItem("test", "value"); + expect(storage.getItem("test")).toBe("value"); + }); + + test("存储并获取对象", () => { + const storage = safeLocalStorage(); + const data = { name: "test", count: 42 }; + storage.setItem("obj", data); + expect(storage.getItem("obj")).toEqual(data); + }); + + test("移除单个项", () => { + const storage = safeLocalStorage(); + storage.setItem("key1", "value1"); + storage.setItem("key2", "value2"); + storage.removeItem("key1"); + expect(storage.getItem("key1")).toBeNull(); + expect(storage.getItem("key2")).toBe("value2"); + }); + + test("清空所有存储", () => { + const storage = safeLocalStorage(); + storage.setItem("key1", "value1"); + storage.setItem("key2", "value2"); + storage.clear(); + expect(storage.getItem("key1")).toBeNull(); + expect(storage.getItem("key2")).toBeNull(); + }); + }); + + describe("异常处理测试", () => { + test("损坏的 JSON 数据", () => { + const storage = safeLocalStorage(); + mockLocalStorage.setItem("badData", "{invalid json"); + expect(storage.getItem("badData")).toBeNull(); + expect(consoleError).toHaveBeenCalledWith("Failed to deserialize value:", expect.any(Error)); + }); + + test("无法序列化的数据", () => { + const storage = safeLocalStorage(); + const circularRef: any = {}; + circularRef.self = circularRef; + + storage.setItem("circular", circularRef); + expect(mockLocalStorage.getItem("circular")).toBe("[object Object]"); + expect(consoleError).toHaveBeenCalledWith("Failed to serialize value:", expect.any(Error)); + }); + }); + + describe("边界条件测试", () => { + test("存储空值", () => { + const storage = safeLocalStorage(); + storage.setItem("empty", null as any); + expect(storage.getItem("empty")).toBeNull(); + }); + + test("获取不存在的键", () => { + const storage = safeLocalStorage(); + expect(storage.getItem("nonExistent")).toBeNull(); + }); + }); +}); diff --git a/packages/mini-markdown-editor/src/utils/handle-scroll.ts b/packages/mini-markdown-editor/src/utils/handle-scroll.ts index 96e42c2..87078e0 100644 --- a/packages/mini-markdown-editor/src/utils/handle-scroll.ts +++ b/packages/mini-markdown-editor/src/utils/handle-scroll.ts @@ -229,8 +229,8 @@ class ScrollSynchronizer { } } -//? 可选导出 -const scrollSynchronizer = new ScrollSynchronizer(); +//? 可选导出 //导出方便测试 +export const scrollSynchronizer = new ScrollSynchronizer(); export const handleEditorScroll = ({ editorView, previewView }: InstancesType): void => { scrollSynchronizer.handleEditorScroll(editorView, previewView); -- Gitee From 87a305282f45c370b1125a2987f96274abbe2c35 Mon Sep 17 00:00:00 2001 From: link <3399652842@qq.com> Date: Sat, 22 Feb 2025 01:54:16 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(mini-markdown-editor):=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90providers=E5=92=8Cstore=E7=9A=84unit=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/config-provider.test.tsx | 87 +++++++++++ .../__test__/toolbar-provider.test.tsx | 142 ++++++++++++++++++ .../src/store/__test__/editor.test.ts | 76 ++++++++++ .../src/store/__test__/toolbar.test.tsx | 120 +++++++++++++++ 4 files changed, 425 insertions(+) create mode 100644 packages/mini-markdown-editor/src/components/providers/__test__/config-provider.test.tsx create mode 100644 packages/mini-markdown-editor/src/components/providers/__test__/toolbar-provider.test.tsx create mode 100644 packages/mini-markdown-editor/src/store/__test__/editor.test.ts create mode 100644 packages/mini-markdown-editor/src/store/__test__/toolbar.test.tsx 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 0000000..39a4242 --- /dev/null +++ b/packages/mini-markdown-editor/src/components/providers/__test__/config-provider.test.tsx @@ -0,0 +1,87 @@ +import { render, screen } from "@testing-library/react"; +import { describe, test, expect } from "vitest"; +import { ConfigContext, ConfigProvider } from "../config-provider"; +import { defaultGlobalConfig } from "@/config/global"; +import type { GlobalConfig } from "@/types/global-config"; +import { useContext } from "react"; + +// 测试用消费者组件 +const ConfigConsumer = () => { + const config = useContext(ConfigContext); + return ( +
+ {config.theme} + {config.locale} +
+ ); +}; + +describe("ConfigProvider Provider测试", () => { + // 测试默认配置 + test("应提供默认配置", () => { + render( + + + , + ); + + expect(screen.getByTestId("theme")).toHaveTextContent(defaultGlobalConfig.theme!); + expect(screen.getByTestId("locale")).toHaveTextContent(defaultGlobalConfig.locale!); + }); + + // 测试自定义配置合并 + test("应合并自定义配置", () => { + const customConfig: GlobalConfig = { + theme: "dark", + }; + + render( + + + , + ); + + expect(screen.getByTestId("theme")).toHaveTextContent("dark"); + expect(screen.getByTestId("locale")).toHaveTextContent(defaultGlobalConfig.locale!); + }); + + // 测试空配置处理 + test("应处理空配置", () => { + render( + + + , + ); + + expect(screen.getByTestId("theme")).toHaveTextContent(defaultGlobalConfig.theme!); + expect(screen.getByTestId("locale")).toHaveTextContent(defaultGlobalConfig.locale!); + }); + + // 测试深层属性覆盖 + test("应深度合并配置", () => { + const customConfig = { + toolbars: { + addTools: [], + excludeTools: [], + }, + } as GlobalConfig; + + const TestComponent = () => { + const config = useContext(ConfigContext); + return {JSON.stringify(config.toolbars)}; + }; + + render( + + + , + ); + + const expected = { + ...defaultGlobalConfig.toolbars, + ...customConfig.toolbars, + }; + + expect(screen.getByTestId("editor")).toHaveTextContent(JSON.stringify(expected)); + }); +}); 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 0000000..ba80eb2 --- /dev/null +++ b/packages/mini-markdown-editor/src/components/providers/__test__/toolbar-provider.test.tsx @@ -0,0 +1,142 @@ +import { render, screen } from "@testing-library/react"; +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { ToolbarProvider, ToolbarContext } from "../toolbar-provider"; +import { toolbarConfig as toolbarManager } from "@/config/toolbar"; +import { useContext } from "react"; + +// 模拟工具栏配置模块 +vi.mock("@/config/toolbar", () => ({ + toolbarConfig: { + getDefaultToolbar: vi.fn(() => [ + { type: "bold", title: "加粗" }, + { type: "italic", title: "斜体" }, + ]), + updateToolbars: vi.fn(), + }, +})); + +// 测试消费者组件 +const TestConsumer = () => { + const context = useContext(ToolbarContext); + return ( +
+ {context?.toolbars.map((item) => ( + + {item.title} + + ))} +
+ ); +}; + +describe("ToolbarProvider Provider测试", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // 基础功能测试 + test("应渲染默认工具栏", () => { + render( + + + , + ); + + expect(screen.getByTestId("bold")).toBeInTheDocument(); + expect(screen.getByTestId("italic")).toBeInTheDocument(); + expect(toolbarManager.updateToolbars).toHaveBeenCalledWith([ + expect.objectContaining({ type: "bold" }), + expect.objectContaining({ type: "italic" }), + ]); + }); + + // 添加工具测试 + test("应合并新增工具", () => { + const customConfig = { + addTools: [{ type: "underline", title: "下划线" }], + }; + + render( + + + , + ); + + expect(screen.getByTestId("bold")).toBeInTheDocument(); + expect(screen.getByTestId("italic")).toBeInTheDocument(); + expect(screen.getByTestId("underline")).toBeInTheDocument(); + }); + + // 排除工具测试 + test("应过滤排除的工具", () => { + const customConfig = { + excludeTools: ["italic"], + }; + + render( + + + , + ); + + expect(screen.getByTestId("bold")).toBeInTheDocument(); + expect(screen.queryByTestId("italic")).toBeNull(); + }); + + // 排序工具测试 + test("应按指定顺序排序工具", () => { + const customConfig = { + orderTools: [{ type: "italic", order: 1 }], + }; + + const { container } = render( + + + , + ); + + const items = container.querySelectorAll("[data-testid]"); + expect(items[0].getAttribute("data-testid")).toBe("italic"); + expect(items[1].getAttribute("data-testid")).toBe("bold"); + }); + + // 组合功能测试 + test("应同时处理添加、排除和排序", () => { + const customConfig = { + addTools: [{ type: "underline", title: "下划线" }], + excludeTools: ["bold"], + orderTools: [{ type: "underline", order: 0 }], + }; + + render( + + + , + ); + + const items = screen.getAllByTestId(/.*/); + expect(items).toHaveLength(2); + expect(items[0]).toHaveAttribute("data-testid", "underline"); + expect(items[1]).toHaveAttribute("data-testid", "italic"); + }); + + // 配置更新测试 + test("配置变化时应更新工具栏", async () => { + const { rerender } = render( + + + , + ); + + expect(screen.queryByTestId("bold")).toBeNull(); + + rerender( + + + , + ); + + expect(screen.getByTestId("bold")).toBeInTheDocument(); + expect(screen.queryByTestId("italic")).toBeNull(); + }); +}); diff --git a/packages/mini-markdown-editor/src/store/__test__/editor.test.ts b/packages/mini-markdown-editor/src/store/__test__/editor.test.ts new file mode 100644 index 0000000..9132f25 --- /dev/null +++ b/packages/mini-markdown-editor/src/store/__test__/editor.test.ts @@ -0,0 +1,76 @@ +import { describe, test, expect, vi } from "vitest"; +import { useEditorContentStore } from "../editor"; +import type { EditorView } from "@codemirror/view"; + +// 模拟 EditorView 和 HTMLElement +const mockEditorView = { destroy: vi.fn() } as unknown as EditorView; +const mockPreviewElement = document.createElement("div"); + +describe("useEditorContentStore Store测试", () => { + // 创建独立的 store 实例用于测试 + const useTestStore = useEditorContentStore; + + test("初始状态应为空值", () => { + const state = useTestStore.getState(); + + expect(state.content).toBe(""); + expect(state.scrollWrapper).toBe(""); + expect(state.editorView).toBeNull(); + expect(state.previewView).toBeNull(); + }); + + test("应正确更新内容", () => { + // 测试普通字符串 + useTestStore.getState().setContent("Hello World"); + expect(useTestStore.getState().content).toBe("Hello World"); + + // 测试特殊字符 + useTestStore.getState().setContent("
HTML
"); + expect(useTestStore.getState().content).toBe("
HTML
"); + }); + + test("应正确设置滚动容器标识", () => { + useTestStore.getState().setScrollWrapper("#editor"); + expect(useTestStore.getState().scrollWrapper).toBe("#editor"); + + useTestStore.getState().setScrollWrapper(""); + expect(useTestStore.getState().scrollWrapper).toBe(""); + }); + + test("应正确处理编辑器视图", () => { + // 设置编辑器视图 + useTestStore.getState().setEditorView(mockEditorView); + expect(useTestStore.getState().editorView).toBe(mockEditorView); + + // 清除编辑器视图 + useTestStore.getState().setEditorView(null); + expect(useTestStore.getState().editorView).toBeNull(); + }); + + test("应正确处理预览视图", () => { + // 设置预览元素 + useTestStore.getState().setPreviewView(mockPreviewElement); + expect(useTestStore.getState().previewView).toBe(mockPreviewElement); + + // 清除预览元素 + useTestStore.getState().setPreviewView(null); + expect(useTestStore.getState().previewView).toBeNull(); + }); + + test("应支持多次连续更新", () => { + // 连续更新内容 + useTestStore.getState().setContent("First"); + useTestStore.getState().setContent("Second"); + expect(useTestStore.getState().content).toBe("Second"); + + // 混合更新不同类型状态 + useTestStore.getState().setScrollWrapper("wrapper"); + useTestStore.getState().setPreviewView(mockPreviewElement); + useTestStore.getState().setEditorView(mockEditorView); + + const finalState = useTestStore.getState(); + expect(finalState.scrollWrapper).toBe("wrapper"); + expect(finalState.previewView).toBe(mockPreviewElement); + expect(finalState.editorView).toBe(mockEditorView); + }); +}); diff --git a/packages/mini-markdown-editor/src/store/__test__/toolbar.test.tsx b/packages/mini-markdown-editor/src/store/__test__/toolbar.test.tsx new file mode 100644 index 0000000..b5dbfe7 --- /dev/null +++ b/packages/mini-markdown-editor/src/store/__test__/toolbar.test.tsx @@ -0,0 +1,120 @@ +import { describe, test, expect } from "vitest"; +import { useToolbarStore } from "../toolbar"; + +// 创建独立的 store 实例用于测试 +const useTestStore = useToolbarStore; + +// 模拟 React 组件 +const MockComponent =
Sidebar
; + +describe("useToolbarStore Store测试", () => { + // 每次测试前重置状态 + beforeEach(() => { + useTestStore.setState({ + isFullScreen: false, + isOnlyWrite: false, + isOnlyPreview: false, + isSidebar: false, + sidebarComponent: null, + componentMark: null, + }); + }); + + test("初始状态应为默认值", () => { + const state = useTestStore.getState(); + + expect(state.isFullScreen).toBe(false); + expect(state.isOnlyWrite).toBe(false); + expect(state.isOnlyPreview).toBe(false); + expect(state.isSidebar).toBe(false); + expect(state.sidebarComponent).toBeNull(); + expect(state.componentMark).toBeNull(); + }); + + describe("全屏状态", () => { + test("应正确切换全屏状态", () => { + useTestStore.getState().setIsFullScreen(true); + expect(useTestStore.getState().isFullScreen).toBe(true); + + useTestStore.getState().setIsFullScreen(false); + expect(useTestStore.getState().isFullScreen).toBe(false); + }); + }); + + describe("编辑/预览模式", () => { + test("setIsOnlyWrite 应切换仅编辑状态并关闭预览", () => { + // 初始状态 + useTestStore.setState({ isOnlyPreview: true }); + + // 第一次切换 + useTestStore.getState().setIsOnlyWrite(); + expect(useTestStore.getState().isOnlyWrite).toBe(true); + expect(useTestStore.getState().isOnlyPreview).toBe(false); + + // 再次切换 + useTestStore.getState().setIsOnlyWrite(); + expect(useTestStore.getState().isOnlyWrite).toBe(false); + }); + + test("setIsOnlyPreview 应切换仅预览状态并关闭编辑", () => { + // 初始状态 + useTestStore.setState({ isOnlyWrite: true }); + + // 第一次切换 + useTestStore.getState().setIsOnlyPreview(); + expect(useTestStore.getState().isOnlyPreview).toBe(true); + expect(useTestStore.getState().isOnlyWrite).toBe(false); + + // 再次切换 + useTestStore.getState().setIsOnlyPreview(); + expect(useTestStore.getState().isOnlyPreview).toBe(false); + }); + }); + + describe("侧边栏控制", () => { + test("设置相同标记应切换显示状态", () => { + const mark = "settings"; + + // 第一次设置 + useTestStore.getState().setSidebar(MockComponent, mark); + expect(useTestStore.getState().isSidebar).toBe(true); + expect(useTestStore.getState().componentMark).toBe(mark); + + // 相同标记再次设置 + useTestStore.getState().setSidebar(MockComponent, mark); + expect(useTestStore.getState().isSidebar).toBe(false); + }); + + test("设置不同标记应更新组件", () => { + // 初始设置 + useTestStore.getState().setSidebar(MockComponent, "settings"); + + // 设置不同标记 + useTestStore.getState().setSidebar(
New
, "help"); + expect(useTestStore.getState().isSidebar).toBe(true); + expect(useTestStore.getState().componentMark).toBe("help"); + }); + }); + + describe("状态互斥测试", () => { + test("全屏模式不应影响其他状态", () => { + useTestStore.getState().setIsFullScreen(true); + useTestStore.getState().setIsOnlyWrite(); + useTestStore.getState().setSidebar(MockComponent, "test"); + + const state = useTestStore.getState(); + expect(state.isFullScreen).toBe(true); + expect(state.isOnlyWrite).toBe(true); + expect(state.isSidebar).toBe(true); + }); + + test("仅写和仅预览应互斥", () => { + useTestStore.getState().setIsOnlyWrite(); + expect(useTestStore.getState().isOnlyPreview).toBe(false); + + useTestStore.getState().setIsOnlyPreview(); + expect(useTestStore.getState().isOnlyWrite).toBe(false); + expect(useTestStore.getState().isOnlyPreview).toBe(true); + }); + }); +}); -- Gitee