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/2] =?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/2] =?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