import { describe, expect, it } from "vitest"; import { blockToMarkdown, blocksToMarkdown, copyBlocks, markdownToBlocks, parsePasted, type CopiedBlock, } from "./clipboard.js"; import type { BlockType } from "@factory/shared"; function copied( id: string, type: BlockType, content: Record, parentId: string | null = null, properties?: Record, ): CopiedBlock { return { id, type, content, parentId, ...(properties !== undefined ? { properties } : {}), position: "0", }; } describe("clipboard — internal copy", () => { it("regenerates ids on copy and keeps the subtree links", () => { const src: CopiedBlock[] = [ copied("blk_old1", "heading_1", { text: "H" }), copied("blk_old2", "paragraph", { text: "P" }, "blk_old1"), ]; const { blocks, idsRegenerated } = copyBlocks(src); expect(idsRegenerated).toBe(true); expect(blocks[0]?.id).not.toBe("blk_old1"); expect(blocks[1]?.id).not.toBe("blk_old2"); // child points at the NEW parent id expect(blocks[1]?.parentId).toBe(blocks[0]?.id); }); it("preserves ids when regenerateIds is false (internal drag)", () => { const src: CopiedBlock[] = [ copied("blk_a", "paragraph", { text: "A" }), copied("blk_b", "paragraph", { text: "B" }, "blk_a"), ]; const { blocks, idsRegenerated } = copyBlocks(src, { regenerateIds: false }); expect(idsRegenerated).toBe(false); expect(blocks.map((b) => b.id)).toEqual(["blk_a", "blk_b"]); expect(blocks[1]?.parentId).toBe("blk_a"); }); }); describe("clipboard — markdown", () => { it("renders every text-ish construct", () => { expect(blockToMarkdown(copied("x", "heading_1", { text: "Hi" }))).toBe("# Hi"); expect(blockToMarkdown(copied("x", "heading_2", { text: "Hi" }))).toBe("## Hi"); expect(blockToMarkdown(copied("x", "heading_3", { text: "Hi" }))).toBe("### Hi"); expect(blockToMarkdown(copied("x", "quote", { text: "Q" }))).toBe("> Q"); expect(blockToMarkdown(copied("x", "bulleted_list", { text: "B" }))).toBe("- B"); expect(blockToMarkdown(copied("x", "numbered_list", { text: "N" }))).toBe("1. N"); expect(blockToMarkdown(copied("x", "todo", { text: "T" }, null, { checked: true }))).toBe("- [x] T"); expect(blockToMarkdown(copied("x", "todo", { text: "T" }, null, { checked: false }))).toBe("- [ ] T"); expect(blockToMarkdown(copied("x", "divider", {}))).toBe("---"); expect(blockToMarkdown(copied("x", "spacer", { height: 32 }))).toBe(""); expect(blockToMarkdown(copied("x", "equation", { text: "a+b" }))).toBe("$a+b$"); expect( blockToMarkdown(copied("x", "code", { text: "let a = 1", language: "ts" })), ).toBe("```ts\nlet a = 1\n```"); expect(blockToMarkdown(copied("x", "bookmark", { url: "https://a.b" }))).toBe("[bookmark](https://a.b)"); expect(blockToMarkdown(copied("x", "paragraph", { text: "hello" }))).toBe("hello"); }); it("parses markdown back into blocks (round trip for the text set)", () => { const src: CopiedBlock[] = [ copied("1", "heading_1", { text: "Title" }), copied("2", "paragraph", { text: "Body" }), copied("3", "todo", { text: "Task" }, null, { checked: true }), copied("4", "quote", { text: "Q" }), copied("5", "divider", {}), copied("6", "code", { text: "x = 1", language: "py" }), ]; const md = blocksToMarkdown(src); const back = markdownToBlocks(md); expect(back.map((b) => b.type)).toEqual([ "heading_1", "paragraph", "todo", "quote", "divider", "code", ]); expect((back[0]?.content as { text: string }).text).toBe("Title"); expect((back[2]?.properties as { checked: boolean }).checked).toBe(true); expect((back[5]?.content as { text: string }).text).toBe("x = 1"); expect((back[5]?.content as { language?: string }).language).toBe("py"); // fresh ids + strictly increasing sequential positions const ids = new Set(back.map((b) => b.id)); expect(ids.size).toBe(back.length); for (let i = 1; i < back.length; i += 1) { expect(back[i]?.position).not.toBe(back[i - 1]?.position); } }); it("parses bullet and numbered lists, spacer comment, equation, reference", () => { const blocks = markdownToBlocks( "- item one\n1. step one\n\n$e=mc^2$\n[bookmark](https://ex.com)", ); expect(blocks.map((b) => b.type)).toEqual([ "bulleted_list", "numbered_list", "spacer", "equation", "bookmark", ]); expect((blocks[2]?.content as { height: number }).height).toBe(16); expect((blocks[3]?.content as { text: string }).text).toBe("e=mc^2"); expect((blocks[4]?.content as { url: string }).url).toBe("https://ex.com"); }); it("degrades plain text to paragraphs", () => { const blocks = markdownToBlocks("just a line\n\nanother"); expect(blocks.map((b) => b.type)).toEqual(["paragraph", "paragraph"]); }); }); describe("parsePasted (source detection)", () => { it("internal JSON round-trips losslessly (ids preserved)", () => { const src: CopiedBlock[] = [ copied("blk_keep", "heading_2", { text: "H" }), copied("blk_child", "paragraph", { text: "P" }, "blk_keep"), ]; const { blocks } = copyBlocks(src, { regenerateIds: false }); const raw = JSON.stringify(blocks); const back = parsePasted(raw, "internal"); expect(back).toHaveLength(2); expect(back[0]?.id).toBe("blk_keep"); expect(back[1]?.parentId).toBe("blk_keep"); }); it("rejects a non-array internal payload", () => { expect(() => parsePasted('{"a":1}', "internal")).toThrow(/must be a block array/); }); it("text source → paragraphs", () => { const blocks = parsePasted("hello", "text"); expect(blocks[0]?.type).toBe("paragraph"); expect((blocks[0]?.content as { text: string }).text).toBe("hello"); }); });