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("- First
- 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("");
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}$/);
});
});