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 0000000000000000000000000000000000000000..8081f3d347baabeebcfa17cc9d31271a8788618e --- /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 0000000000000000000000000000000000000000..d7a41ccd87fd88586b1ede86aa3f9fb70d1a0232 --- /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 0000000000000000000000000000000000000000..8cf53b0dbcc9243cc029c08d9bc8e890c918f261 --- /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 0000000000000000000000000000000000000000..b551cac653e9a5ba76ba2e6f453401eafc283e54 --- /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 0000000000000000000000000000000000000000..0029018e2e3bd1818ce9b5efe59bdcd02aec8e78 --- /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 0000000000000000000000000000000000000000..00117da650773a625bdef84029477dfb8d117a11 --- /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 0000000000000000000000000000000000000000..af4210b8bb46f3c46ceb2a45b466ece67c6e2add --- /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 0000000000000000000000000000000000000000..cc45170328f93945b7531cc2bcbcaa646f0a9a33 --- /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 0000000000000000000000000000000000000000..3833a93e38aaafdc631556321e9146499af6c21d --- /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 96e42c230512a3137c7b446a20e6213bc5d938f6..87078e0859dfbe918b7dd937b61560f3b4d4bc0f 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);