import { describe, expect, it } from "vitest"; import { CLIPBOARD_APP_MARKER, copySubtreeToClipboardPayload, generateClipboardBlockId, htmlHasBlockStructure, parseClipboardText, pasteHtmlToBlocks, pasteTextToBlocks } from "../clipboard-import"; /** * `noUncheckedIndexedAccess` is on: index/destructure of `T[]` yields * `T | undefined`. The specs arrays here are asserted non-empty right before * each access, so the `!` is sound. */ describe("pasteHtmlToBlocks (structural HTML → block tree)", () => { it("maps headings, paragraphs, quotes, rules", () => { const specs = pasteHtmlToBlocks( "

Title

Para bold

Quoted

H2

H3

" ); expect(specs.map((s) => s.type)).toEqual(["heading_1", "paragraph", "quote", "divider", "heading_2", "heading_3"]); expect(specs[1]!.content).toContain("bold"); // inline formatting survives expect(specs.every((s) => s.parentId === null)).toBe(true); expect(new Set(specs.map((s) => s.id)).size).toBe(specs.length); // unique ids }); it("preserves list indentation as parent-child structure", () => { const specs = pasteHtmlToBlocks(""); expect(specs.map((s) => s.type)).toEqual(["bulleted_list", "bulleted_list", "bulleted_list", "bulleted_list"]); const a = specs[0]!; const b = specs[1]!; const c = specs[2]!; const d = specs[3]!; expect(a.parentId).toBeNull(); expect(b.parentId).toBe(a.id); // indented under A expect(c.parentId).toBe(a.id); expect(d.parentId).toBeNull(); expect(b.content).toContain("B"); }); it("maps ordered lists to numbered_list", () => { const specs = pasteHtmlToBlocks("
  1. First
  2. Second
"); expect(specs.map((s) => s.type)).toEqual(["numbered_list", "numbered_list"]); }); it("maps checkbox list items to todo with checked state", () => { const specs = pasteHtmlToBlocks( '' ); expect(specs.map((s) => s.type)).toEqual(["todo", "todo"]); expect(specs[0]!.properties).toEqual({ checked: true }); expect(specs[1]!.properties).toEqual({ checked: false }); }); it("maps a Notion toggle to a toggle block with nested children", () => { const html = ''; const specs = pasteHtmlToBlocks(html); expect(specs).toHaveLength(3); const t = specs[0]!; const c1 = specs[1]!; const c2 = specs[2]!; expect(t.type).toBe("toggle"); expect(t.content).toContain("Section"); expect(t.content).toContain("title"); // The toggle title must NOT swallow its children's text. expect(t.content).not.toContain("Child one"); expect(t.content).not.toContain("Child two"); expect(c1.type).toBe("bulleted_list"); expect(c1.parentId).toBe(t.id); expect(c2.parentId).toBe(t.id); // Document order: parent before its children (batch-insert safe). expect(specs.indexOf(t)).toBeLessThan(specs.indexOf(c1)); }); it("handles nested toggles (toggle inside toggle)", () => { const html = ''; const specs = pasteHtmlToBlocks(html); const outer = specs[0]!; const inner = specs[1]!; const deep = specs[2]!; expect(outer.type).toBe("toggle"); expect(inner.type).toBe("toggle"); expect(inner.parentId).toBe(outer.id); expect(deep.parentId).toBe(inner.id); expect(outer.content).not.toContain("Inner"); expect(outer.content).not.toContain("Deep"); }); it("wraps lists in a div (Notion export shape)", () => { const specs = pasteHtmlToBlocks('
'); expect(specs).toHaveLength(1); expect(specs[0]!.type).toBe("bulleted_list"); expect(specs[0]!.content).toContain("Wrapped"); }); it("keeps table text as a paragraph (no table in the MVP)", () => { const specs = pasteHtmlToBlocks("
ab
"); expect(specs).toHaveLength(1); expect(specs[0]!.type).toBe("paragraph"); expect(String(specs[0]!.content)).toContain("a"); }); it("returns [] for empty / unrecognizable HTML", () => { expect(pasteHtmlToBlocks("")).toEqual([]); expect(pasteHtmlToBlocks("inline only")).toEqual([]); }); }); describe("pasteTextToBlocks", () => { it("maps lines to paragraphs, escapes markup, drops one trailing newline", () => { const specs = pasteTextToBlocks("Line one\nLine two\n\n\n"); expect(specs.map((s) => s.type)).toEqual(["paragraph", "paragraph", "paragraph", "paragraph"]); expect(specs[0]!.content).toBe("Line one"); expect(specs[3]!.content).toBe("<not html>"); }); }); describe("internal clipboard payload (copy/paste of subtrees)", () => { const tree: Array<{ id: string; parentId: string | null; type: string; content: unknown; properties: unknown }> = [ { id: "blk_root", parentId: null, type: "toggle", content: "Root", properties: { collapsed: false } }, { id: "blk_c1", parentId: "blk_root", type: "paragraph", content: "Child", properties: {} }, { id: "blk_c2", parentId: "blk_root", type: "todo", content: "Task", properties: { checked: true } }, { id: "blk_top", parentId: null, type: "paragraph", content: "Sibling", properties: {} } ]; it("copySubtreeToClipboardPayload emits fresh ids + parent links in tree order", () => { const specs = copySubtreeToClipboardPayload("blk_root", tree); expect(specs).toHaveLength(3); const s0 = specs[0]!; const s1 = specs[1]!; const s2 = specs[2]!; expect(s0.type).toBe("toggle"); expect(s0.parentId).toBeNull(); expect(s1.parentId).toBe(s0.id); expect(s2.parentId).toBe(s0.id); expect(s2.properties).toEqual({ checked: true }); // Fresh ids — never the source ids. expect(specs.every((s) => !["blk_root", "blk_c1", "blk_c2"].includes(s.id))).toBe(true); // Every id is a fresh client id. expect(specs.every((s) => s.id.startsWith("blk_"))).toBe(true); }); it("parses the payload back to specs", () => { const payload = JSON.stringify({ app: CLIPBOARD_APP_MARKER, v: 1, blocks: copySubtreeToClipboardPayload("blk_root", tree) }); const specs = parseClipboardText(payload); expect(specs).toHaveLength(3); expect(specs![0]!.type).toBe("toggle"); }); it("parses the legacy single-block shape", () => { const legacy = JSON.stringify({ id: "blk_x", type: "heading_1", content: "Hi", properties: {}, parentId: null }); const specs = parseClipboardText(legacy); expect(specs).toHaveLength(1); expect(specs![0]!.type).toBe("heading_1"); expect(specs![0]!.content).toBe("Hi"); }); it("returns null for non-JSON and foreign JSON", () => { expect(parseClipboardText("just some text")).toBeNull(); expect(parseClipboardText("")).toBeNull(); expect(parseClipboardText(JSON.stringify({ foo: 1 }))).toBeNull(); expect(parseClipboardText("{broken")).toBeNull(); }); }); describe("htmlHasBlockStructure", () => { it("flags block-level markup", () => { expect(htmlHasBlockStructure("")).toBe(true); expect(htmlHasBlockStructure('
  • t
  • ')).toBe(true); expect(htmlHasBlockStructure("

    t

    ")).toBe(true); expect(htmlHasBlockStructure("
    s
    ")).toBe(true); expect(htmlHasBlockStructure("
    ")).toBe(true); }); it("ignores inline-only HTML (the editor's own paste stays inline)", () => { expect(htmlHasBlockStructure("bold and italic")).toBe(false); expect(htmlHasBlockStructure('x')).toBe(false); }); }); describe("generateClipboardBlockId", () => { it("generates unique blk_-prefixed ids", () => { const ids = new Set(Array.from({ length: 1000 }, () => generateClipboardBlockId())); expect(ids.size).toBe(1000); for (const id of ids) expect(id).toMatch(/^blk_[0-9a-f]{32}$/); }); });