1import { describe, expect, it } from "vitest";
2import {
3 CLIPBOARD_APP_MARKER,
4 copySubtreeToClipboardPayload,
5 generateClipboardBlockId,
6 htmlHasBlockStructure,
7 parseClipboardText,
8 pasteHtmlToBlocks,
9 pasteTextToBlocks
10} from "../clipboard-import";
11
12
17
18describe("pasteHtmlToBlocks (structural HTML → block tree)", () => {
19 it("maps headings, paragraphs, quotes, rules", () => {
20 const specs = pasteHtmlToBlocks(
21 "<h1>Title</h1><p>Para <b>bold</b></p><blockquote>Quoted</blockquote><hr><h2>H2</h2><h3>H3</h3>"
22 );
23 expect(specs.map((s) => s.type)).toEqual(["heading_1", "paragraph", "quote", "divider", "heading_2", "heading_3"]);
24 expect(specs[1]!.content).toContain("<b>bold</b>");
25 expect(specs.every((s) => s.parentId === null)).toBe(true);
26 expect(new Set(specs.map((s) => s.id)).size).toBe(specs.length);
27 });
28
29 it("preserves list indentation as parent-child structure", () => {
30 const specs = pasteHtmlToBlocks("<ul><li>A<ul><li>B</li><li>C</li></ul></li><li>D</li></ul>");
31 expect(specs.map((s) => s.type)).toEqual(["bulleted_list", "bulleted_list", "bulleted_list", "bulleted_list"]);
32 const a = specs[0]!;
33 const b = specs[1]!;
34 const c = specs[2]!;
35 const d = specs[3]!;
36 expect(a.parentId).toBeNull();
37 expect(b.parentId).toBe(a.id);
38 expect(c.parentId).toBe(a.id);
39 expect(d.parentId).toBeNull();
40 expect(b.content).toContain("B");
41 });
42
43 it("maps ordered lists to numbered_list", () => {
44 const specs = pasteHtmlToBlocks("<ol><li>First</li><li>Second</li></ol>");
45 expect(specs.map((s) => s.type)).toEqual(["numbered_list", "numbered_list"]);
46 });
47
48 it("maps checkbox list items to todo with checked state", () => {
49 const specs = pasteHtmlToBlocks(
50 '<ul><li><input type="checkbox" checked="checked">Done</li><li><input type="checkbox">Open</li></ul>'
51 );
52 expect(specs.map((s) => s.type)).toEqual(["todo", "todo"]);
53 expect(specs[0]!.properties).toEqual({ checked: true });
54 expect(specs[1]!.properties).toEqual({ checked: false });
55 });
56
57 it("maps a Notion toggle to a toggle block with nested children", () => {
58 const html =
59 '<ul><li data-notion="toggle">Section <b>title</b><ul><li>Child one</li><li>Child two</li></ul></li></ul>';
60 const specs = pasteHtmlToBlocks(html);
61 expect(specs).toHaveLength(3);
62 const t = specs[0]!;
63 const c1 = specs[1]!;
64 const c2 = specs[2]!;
65 expect(t.type).toBe("toggle");
66 expect(t.content).toContain("Section");
67 expect(t.content).toContain("<b>title</b>");
68
69 expect(t.content).not.toContain("Child one");
70 expect(t.content).not.toContain("Child two");
71 expect(c1.type).toBe("bulleted_list");
72 expect(c1.parentId).toBe(t.id);
73 expect(c2.parentId).toBe(t.id);
74
75 expect(specs.indexOf(t)).toBeLessThan(specs.indexOf(c1));
76 });
77
78 it("handles nested toggles (toggle inside toggle)", () => {
79 const html =
80 '<ul><li data-notion="toggle">Outer<ul><li data-notion="toggle">Inner<ul><li>Deep</li></ul></li></ul></li></ul>';
81 const specs = pasteHtmlToBlocks(html);
82 const outer = specs[0]!;
83 const inner = specs[1]!;
84 const deep = specs[2]!;
85 expect(outer.type).toBe("toggle");
86 expect(inner.type).toBe("toggle");
87 expect(inner.parentId).toBe(outer.id);
88 expect(deep.parentId).toBe(inner.id);
89 expect(outer.content).not.toContain("Inner");
90 expect(outer.content).not.toContain("Deep");
91 });
92
93 it("wraps lists in a div (Notion export shape)", () => {
94 const specs = pasteHtmlToBlocks('<div><ul><li>Wrapped</li></ul></div>');
95 expect(specs).toHaveLength(1);
96 expect(specs[0]!.type).toBe("bulleted_list");
97 expect(specs[0]!.content).toContain("Wrapped");
98 });
99
100 it("keeps table text as a paragraph (no table in the MVP)", () => {
101 const specs = pasteHtmlToBlocks("<table><tr><td>a</td><td>b</td></tr></table>");
102 expect(specs).toHaveLength(1);
103 expect(specs[0]!.type).toBe("paragraph");
104 expect(String(specs[0]!.content)).toContain("a");
105 });
106
107 it("returns [] for empty / unrecognizable HTML", () => {
108 expect(pasteHtmlToBlocks("")).toEqual([]);
109 expect(pasteHtmlToBlocks("<span>inline only</span>")).toEqual([]);
110 });
111});
112
113describe("pasteTextToBlocks", () => {
114 it("maps lines to paragraphs, escapes markup, drops one trailing newline", () => {
115 const specs = pasteTextToBlocks("Line one\nLine two\n\n<not html>\n");
116 expect(specs.map((s) => s.type)).toEqual(["paragraph", "paragraph", "paragraph", "paragraph"]);
117 expect(specs[0]!.content).toBe("Line one");
118 expect(specs[3]!.content).toBe("<not html>");
119 });
120});
121
122describe("internal clipboard payload (copy/paste of subtrees)", () => {
123 const tree: Array<{ id: string; parentId: string | null; type: string; content: unknown; properties: unknown }> = [
124 { id: "blk_root", parentId: null, type: "toggle", content: "Root", properties: { collapsed: false } },
125 { id: "blk_c1", parentId: "blk_root", type: "paragraph", content: "Child", properties: {} },
126 { id: "blk_c2", parentId: "blk_root", type: "todo", content: "Task", properties: { checked: true } },
127 { id: "blk_top", parentId: null, type: "paragraph", content: "Sibling", properties: {} }
128 ];
129
130 it("copySubtreeToClipboardPayload emits fresh ids + parent links in tree order", () => {
131 const specs = copySubtreeToClipboardPayload("blk_root", tree);
132 expect(specs).toHaveLength(3);
133 const s0 = specs[0]!;
134 const s1 = specs[1]!;
135 const s2 = specs[2]!;
136 expect(s0.type).toBe("toggle");
137 expect(s0.parentId).toBeNull();
138 expect(s1.parentId).toBe(s0.id);
139 expect(s2.parentId).toBe(s0.id);
140 expect(s2.properties).toEqual({ checked: true });
141
142 expect(specs.every((s) => !["blk_root", "blk_c1", "blk_c2"].includes(s.id))).toBe(true);
143
144 expect(specs.every((s) => s.id.startsWith("blk_"))).toBe(true);
145 });
146
147 it("parses the payload back to specs", () => {
148 const payload = JSON.stringify({
149 app: CLIPBOARD_APP_MARKER,
150 v: 1,
151 blocks: copySubtreeToClipboardPayload("blk_root", tree)
152 });
153 const specs = parseClipboardText(payload);
154 expect(specs).toHaveLength(3);
155 expect(specs![0]!.type).toBe("toggle");
156 });
157
158 it("parses the legacy single-block shape", () => {
159 const legacy = JSON.stringify({ id: "blk_x", type: "heading_1", content: "Hi", properties: {}, parentId: null });
160 const specs = parseClipboardText(legacy);
161 expect(specs).toHaveLength(1);
162 expect(specs![0]!.type).toBe("heading_1");
163 expect(specs![0]!.content).toBe("Hi");
164 });
165
166 it("returns null for non-JSON and foreign JSON", () => {
167 expect(parseClipboardText("just some text")).toBeNull();
168 expect(parseClipboardText("")).toBeNull();
169 expect(parseClipboardText(JSON.stringify({ foo: 1 }))).toBeNull();
170 expect(parseClipboardText("{broken")).toBeNull();
171 });
172});
173
174describe("htmlHasBlockStructure", () => {
175 it("flags block-level markup", () => {
176 expect(htmlHasBlockStructure("<ul><li>x</li></ul>")).toBe(true);
177 expect(htmlHasBlockStructure('<li data-notion="toggle">t</li>')).toBe(true);
178 expect(htmlHasBlockStructure("<h1>t</h1>")).toBe(true);
179 expect(htmlHasBlockStructure("<details><summary>s</summary></details>")).toBe(true);
180 expect(htmlHasBlockStructure("<hr>")).toBe(true);
181 });
182
183 it("ignores inline-only HTML (the editor's own paste stays inline)", () => {
184 expect(htmlHasBlockStructure("<b>bold</b> and <i>italic</i>")).toBe(false);
185 expect(htmlHasBlockStructure('<span style="color:red">x</span>')).toBe(false);
186 });
187});
188
189describe("generateClipboardBlockId", () => {
190 it("generates unique blk_-prefixed ids", () => {
191 const ids = new Set(Array.from({ length: 1000 }, () => generateClipboardBlockId()));
192 expect(ids.size).toBe(1000);
193 for (const id of ids) expect(id).toMatch(/^blk_[0-9a-f]{32}$/);
194 });
195});
196