1import { describe, expect, it } from "vitest";
2
3import {
4 blockToMarkdown,
5 blocksToMarkdown,
6 copyBlocks,
7 markdownToBlocks,
8 parsePasted,
9 type CopiedBlock,
10} from "./clipboard.js";
11import type { BlockType } from "@factory/shared";
12
13function copied(
14 id: string,
15 type: BlockType,
16 content: Record<string, unknown>,
17 parentId: string | null = null,
18 properties?: Record<string, unknown>,
19): CopiedBlock {
20 return {
21 id,
22 type,
23 content,
24 parentId,
25 ...(properties !== undefined ? { properties } : {}),
26 position: "0",
27 };
28}
29
30describe("clipboard — internal copy", () => {
31 it("regenerates ids on copy and keeps the subtree links", () => {
32 const src: CopiedBlock[] = [
33 copied("blk_old1", "heading_1", { text: "H" }),
34 copied("blk_old2", "paragraph", { text: "P" }, "blk_old1"),
35 ];
36 const { blocks, idsRegenerated } = copyBlocks(src);
37 expect(idsRegenerated).toBe(true);
38 expect(blocks[0]?.id).not.toBe("blk_old1");
39 expect(blocks[1]?.id).not.toBe("blk_old2");
40
41 expect(blocks[1]?.parentId).toBe(blocks[0]?.id);
42 });
43
44 it("preserves ids when regenerateIds is false (internal drag)", () => {
45 const src: CopiedBlock[] = [
46 copied("blk_a", "paragraph", { text: "A" }),
47 copied("blk_b", "paragraph", { text: "B" }, "blk_a"),
48 ];
49 const { blocks, idsRegenerated } = copyBlocks(src, { regenerateIds: false });
50 expect(idsRegenerated).toBe(false);
51 expect(blocks.map((b) => b.id)).toEqual(["blk_a", "blk_b"]);
52 expect(blocks[1]?.parentId).toBe("blk_a");
53 });
54});
55
56describe("clipboard — markdown", () => {
57 it("renders every text-ish construct", () => {
58 expect(blockToMarkdown(copied("x", "heading_1", { text: "Hi" }))).toBe("# Hi");
59 expect(blockToMarkdown(copied("x", "heading_2", { text: "Hi" }))).toBe("## Hi");
60 expect(blockToMarkdown(copied("x", "heading_3", { text: "Hi" }))).toBe("### Hi");
61 expect(blockToMarkdown(copied("x", "quote", { text: "Q" }))).toBe("> Q");
62 expect(blockToMarkdown(copied("x", "bulleted_list", { text: "B" }))).toBe("- B");
63 expect(blockToMarkdown(copied("x", "numbered_list", { text: "N" }))).toBe("1. N");
64 expect(blockToMarkdown(copied("x", "todo", { text: "T" }, null, { checked: true }))).toBe("- [x] T");
65 expect(blockToMarkdown(copied("x", "todo", { text: "T" }, null, { checked: false }))).toBe("- [ ] T");
66 expect(blockToMarkdown(copied("x", "divider", {}))).toBe("---");
67 expect(blockToMarkdown(copied("x", "spacer", { height: 32 }))).toBe("<!-- spacer:32px -->");
68 expect(blockToMarkdown(copied("x", "equation", { text: "a+b" }))).toBe("$a+b$");
69 expect(
70 blockToMarkdown(copied("x", "code", { text: "let a = 1", language: "ts" })),
71 ).toBe("```ts\nlet a = 1\n```");
72 expect(blockToMarkdown(copied("x", "bookmark", { url: "https://a.b" }))).toBe("[bookmark](https://a.b)");
73 expect(blockToMarkdown(copied("x", "paragraph", { text: "hello" }))).toBe("hello");
74 });
75
76 it("parses markdown back into blocks (round trip for the text set)", () => {
77 const src: CopiedBlock[] = [
78 copied("1", "heading_1", { text: "Title" }),
79 copied("2", "paragraph", { text: "Body" }),
80 copied("3", "todo", { text: "Task" }, null, { checked: true }),
81 copied("4", "quote", { text: "Q" }),
82 copied("5", "divider", {}),
83 copied("6", "code", { text: "x = 1", language: "py" }),
84 ];
85 const md = blocksToMarkdown(src);
86 const back = markdownToBlocks(md);
87 expect(back.map((b) => b.type)).toEqual([
88 "heading_1",
89 "paragraph",
90 "todo",
91 "quote",
92 "divider",
93 "code",
94 ]);
95 expect((back[0]?.content as { text: string }).text).toBe("Title");
96 expect((back[2]?.properties as { checked: boolean }).checked).toBe(true);
97 expect((back[5]?.content as { text: string }).text).toBe("x = 1");
98 expect((back[5]?.content as { language?: string }).language).toBe("py");
99
100 const ids = new Set(back.map((b) => b.id));
101 expect(ids.size).toBe(back.length);
102 for (let i = 1; i < back.length; i += 1) {
103 expect(back[i]?.position).not.toBe(back[i - 1]?.position);
104 }
105 });
106
107 it("parses bullet and numbered lists, spacer comment, equation, reference", () => {
108 const blocks = markdownToBlocks(
109 "- item one\n1. step one\n<!-- spacer:16px -->\n$e=mc^2$\n[bookmark](https://ex.com)",
110 );
111 expect(blocks.map((b) => b.type)).toEqual([
112 "bulleted_list",
113 "numbered_list",
114 "spacer",
115 "equation",
116 "bookmark",
117 ]);
118 expect((blocks[2]?.content as { height: number }).height).toBe(16);
119 expect((blocks[3]?.content as { text: string }).text).toBe("e=mc^2");
120 expect((blocks[4]?.content as { url: string }).url).toBe("https://ex.com");
121 });
122
123 it("degrades plain text to paragraphs", () => {
124 const blocks = markdownToBlocks("just a line\n\nanother");
125 expect(blocks.map((b) => b.type)).toEqual(["paragraph", "paragraph"]);
126 });
127});
128
129describe("parsePasted (source detection)", () => {
130 it("internal JSON round-trips losslessly (ids preserved)", () => {
131 const src: CopiedBlock[] = [
132 copied("blk_keep", "heading_2", { text: "H" }),
133 copied("blk_child", "paragraph", { text: "P" }, "blk_keep"),
134 ];
135 const { blocks } = copyBlocks(src, { regenerateIds: false });
136 const raw = JSON.stringify(blocks);
137 const back = parsePasted(raw, "internal");
138 expect(back).toHaveLength(2);
139 expect(back[0]?.id).toBe("blk_keep");
140 expect(back[1]?.parentId).toBe("blk_keep");
141 });
142
143 it("rejects a non-array internal payload", () => {
144 expect(() => parsePasted('{"a":1}', "internal")).toThrow(/must be a block array/);
145 });
146
147 it("text source → paragraphs", () => {
148 const blocks = parsePasted("hello", "text");
149 expect(blocks[0]?.type).toBe("paragraph");
150 expect((blocks[0]?.content as { text: string }).text).toBe("hello");
151 });
152});
153