|
@@ -0,0 +1,297 @@
|
|
|
|
|
+import { describe, expect, it, vi } from "vitest"
|
|
|
|
|
+import type { EditorBlock } from "@/lib/block-parser"
|
|
|
|
|
+import {
|
|
|
|
|
+ deleteIfEmpty,
|
|
|
|
|
+ indentBlock,
|
|
|
|
|
+ insertBlock,
|
|
|
|
|
+ mergePrevious,
|
|
|
|
|
+ outdentBlock,
|
|
|
|
|
+ setBlockContent,
|
|
|
|
|
+ splitBlocks,
|
|
|
|
|
+} from "@/lib/editor-operations"
|
|
|
|
|
+
|
|
|
|
|
+function block(overrides: Partial<EditorBlock> & { content: string }): EditorBlock {
|
|
|
|
|
+ return { id: "auto", depth: 0, ...overrides }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Return a stable ID generator for deterministic tests.
|
|
|
|
|
+ */
|
|
|
|
|
+function stableId(prefix: string): () => string {
|
|
|
|
|
+ let n = 0
|
|
|
|
|
+ return () => `${prefix}-${n++}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── splitBlocks ───────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("splitBlocks", () => {
|
|
|
|
|
+ it("splits a block into two at the given index", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", depth: 0, content: "first" }),
|
|
|
|
|
+ block({ id: "b", depth: 1, content: "second" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const ids = stableId("new")
|
|
|
|
|
+ const result = splitBlocks(blocks, 1, "bef", "aft", ids)
|
|
|
|
|
+
|
|
|
|
|
+ // Original block gets `before` content
|
|
|
|
|
+ expect(result.blocks[0]).toMatchObject({ id: "a", content: "first" })
|
|
|
|
|
+
|
|
|
|
|
+ // Original at index is updated
|
|
|
|
|
+ expect(result.blocks[1]).toMatchObject({ id: "b", content: "bef" })
|
|
|
|
|
+
|
|
|
|
|
+ // New block inserted after
|
|
|
|
|
+ expect(result.blocks[2]).toMatchObject({ id: "new-0", depth: 1, content: "aft" })
|
|
|
|
|
+
|
|
|
|
|
+ expect(result.blocks).toHaveLength(3)
|
|
|
|
|
+ expect(result.newBlock).toMatchObject({ id: "new-0", content: "aft" })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("preserves depth of the original block", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", depth: 2, content: "x" })]
|
|
|
|
|
+ const result = splitBlocks(blocks, 0, "a", "b", stableId("n"))
|
|
|
|
|
+ expect(result.blocks[0].depth).toBe(2)
|
|
|
|
|
+ expect(result.blocks[1].depth).toBe(2)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("returns original array copy when index is out of bounds", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", content: "x" })]
|
|
|
|
|
+ expect(splitBlocks(blocks, -1, "", "", stableId("n")).newBlock).toBeNull()
|
|
|
|
|
+ expect(splitBlocks(blocks, 5, "", "", stableId("n")).newBlock).toBeNull()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does not mutate the original array", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", depth: 1, content: "original" })]
|
|
|
|
|
+ const copy = [...blocks]
|
|
|
|
|
+ splitBlocks(blocks, 0, "before", "after", stableId("n"))
|
|
|
|
|
+ expect(blocks).toEqual(copy)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// ── mergePrevious ─────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("mergePrevious", () => {
|
|
|
|
|
+ it("merges two adjacent blocks, appending content", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", content: "hello" }),
|
|
|
|
|
+ block({ id: "b", content: " world" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = mergePrevious(blocks, 1)
|
|
|
|
|
+
|
|
|
|
|
+ expect(result.blocks).toHaveLength(1)
|
|
|
|
|
+ expect(result.blocks[0]).toMatchObject({ id: "a", content: "hello world" })
|
|
|
|
|
+ expect(result.mergedContent).toBe("hello world")
|
|
|
|
|
+ expect(result.joinPos).toBe(6) // "hello".length + 1
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("joinPos is 0 when previous block is empty", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", content: "" }),
|
|
|
|
|
+ block({ id: "b", content: "content" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = mergePrevious(blocks, 1)
|
|
|
|
|
+ expect(result.joinPos).toBe(0)
|
|
|
|
|
+ expect(result.blocks[0].content).toBe("content")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("returns no change when index is 0 or out of bounds", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", content: "x" }), block({ id: "b", content: "y" })]
|
|
|
|
|
+ const atZero = mergePrevious(blocks, 0)
|
|
|
|
|
+ expect(atZero.blocks).toHaveLength(2)
|
|
|
|
|
+
|
|
|
|
|
+ const oob = mergePrevious(blocks, 5)
|
|
|
|
|
+ expect(oob.blocks).toHaveLength(2)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does not mutate the original array", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", content: "hello" }),
|
|
|
|
|
+ block({ id: "b", content: " world" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const copy = [...blocks]
|
|
|
|
|
+ mergePrevious(blocks, 1)
|
|
|
|
|
+ expect(blocks).toEqual(copy)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// ── deleteIfEmpty ─────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("deleteIfEmpty", () => {
|
|
|
|
|
+ it("removes a block at the given index", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", content: "first" }),
|
|
|
|
|
+ block({ id: "b", content: "second" }),
|
|
|
|
|
+ block({ id: "c", content: "third" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = deleteIfEmpty(blocks, 1)
|
|
|
|
|
+ expect(result.blocks).toHaveLength(2)
|
|
|
|
|
+ expect(result.blocks[0].id).toBe("a")
|
|
|
|
|
+ expect(result.blocks[1].id).toBe("c")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("focuses the next block when removing from the beginning", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", content: "first" }),
|
|
|
|
|
+ block({ id: "b", content: "second" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = deleteIfEmpty(blocks, 0)
|
|
|
|
|
+ expect(result.nextId).toBe("b")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("focuses the previous block when removing the last block", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", content: "first" }),
|
|
|
|
|
+ block({ id: "b", content: "second" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = deleteIfEmpty(blocks, 1)
|
|
|
|
|
+ expect(result.nextId).toBe("a")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does nothing when only one block exists", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", content: "only" })]
|
|
|
|
|
+ const result = deleteIfEmpty(blocks, 0)
|
|
|
|
|
+ expect(result.blocks).toHaveLength(1)
|
|
|
|
|
+ expect(result.nextId).toBeNull()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does not mutate the original array", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", content: "x" }),
|
|
|
|
|
+ block({ id: "b", content: "y" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const copy = [...blocks]
|
|
|
|
|
+ deleteIfEmpty(blocks, 0)
|
|
|
|
|
+ expect(blocks).toEqual(copy)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// ── indentBlock ───────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("indentBlock", () => {
|
|
|
|
|
+ it("increments depth of the block", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", depth: 0, content: "x" })]
|
|
|
|
|
+ const result = indentBlock(blocks, 0)
|
|
|
|
|
+ expect(result.blocks[0].depth).toBe(1)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("also indents child blocks", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "p", depth: 0, content: "parent" }),
|
|
|
|
|
+ block({ id: "c", depth: 1, content: "child" }),
|
|
|
|
|
+ block({ id: "g", depth: 2, content: "grandchild" }),
|
|
|
|
|
+ block({ id: "s", depth: 0, content: "sibling" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = indentBlock(blocks, 0)
|
|
|
|
|
+ expect(result.childIndices).toEqual([1, 2])
|
|
|
|
|
+ expect(result.blocks[0].depth).toBe(1)
|
|
|
|
|
+ expect(result.blocks[1].depth).toBe(2)
|
|
|
|
|
+ expect(result.blocks[2].depth).toBe(3)
|
|
|
|
|
+ // Sibling at same depth as parent is NOT indented
|
|
|
|
|
+ expect(result.blocks[3].depth).toBe(0)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("caps depth at 10", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", depth: 10, content: "x" })]
|
|
|
|
|
+ const result = indentBlock(blocks, 0)
|
|
|
|
|
+ expect(result.blocks[0].depth).toBe(10)
|
|
|
|
|
+ expect(result.childIndices).toEqual([])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does not mutate the original array", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", depth: 0, content: "x" })]
|
|
|
|
|
+ const copy = [...blocks]
|
|
|
|
|
+ indentBlock(blocks, 0)
|
|
|
|
|
+ expect(blocks).toEqual(copy)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// ── outdentBlock ──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("outdentBlock", () => {
|
|
|
|
|
+ it("decrements depth of the block", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", depth: 2, content: "x" })]
|
|
|
|
|
+ const result = outdentBlock(blocks, 0)
|
|
|
|
|
+ expect(result.blocks[0].depth).toBe(1)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("also outdents child blocks", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "p", depth: 2, content: "parent" }),
|
|
|
|
|
+ block({ id: "c", depth: 3, content: "child" }),
|
|
|
|
|
+ block({ id: "g", depth: 4, content: "grandchild" }),
|
|
|
|
|
+ block({ id: "s", depth: 0, content: "sibling" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = outdentBlock(blocks, 0)
|
|
|
|
|
+ expect(result.blocks[0].depth).toBe(1)
|
|
|
|
|
+ expect(result.blocks[1].depth).toBe(2)
|
|
|
|
|
+ expect(result.blocks[2].depth).toBe(3)
|
|
|
|
|
+ // Sibling not affected
|
|
|
|
|
+ expect(result.blocks[3].depth).toBe(0)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("floors depth at 0", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", depth: 0, content: "x" })]
|
|
|
|
|
+ const result = outdentBlock(blocks, 0)
|
|
|
|
|
+ expect(result.blocks[0].depth).toBe(0)
|
|
|
|
|
+ expect(result.childIndices).toEqual([])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does not mutate the original array", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", depth: 2, content: "x" })]
|
|
|
|
|
+ const copy = [...blocks]
|
|
|
|
|
+ outdentBlock(blocks, 0)
|
|
|
|
|
+ expect(blocks).toEqual(copy)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// ── insertBlock ───────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("insertBlock", () => {
|
|
|
|
|
+ it("inserts a block at the given index", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", content: "first" }),
|
|
|
|
|
+ block({ id: "b", content: "last" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const ids = stableId("new")
|
|
|
|
|
+ const result = insertBlock(blocks, 1, "middle", 0, ids)
|
|
|
|
|
+
|
|
|
|
|
+ expect(result.blocks).toHaveLength(3)
|
|
|
|
|
+ expect(result.blocks[0].id).toBe("a")
|
|
|
|
|
+ expect(result.blocks[1]).toMatchObject({ id: "new-0", content: "middle" })
|
|
|
|
|
+ expect(result.blocks[2].id).toBe("b")
|
|
|
|
|
+ expect(result.newBlock).toMatchObject({ id: "new-0", content: "middle" })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("inserts at the beginning", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "b", content: "second" })]
|
|
|
|
|
+ const result = insertBlock(blocks, 0, "first", 0, stableId("n"))
|
|
|
|
|
+ expect(result.blocks).toHaveLength(2)
|
|
|
|
|
+ expect(result.blocks[0].content).toBe("first")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does not mutate the original array", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", content: "x" })]
|
|
|
|
|
+ const copy = [...blocks]
|
|
|
|
|
+ insertBlock(blocks, 0, "y", 0, stableId("n"))
|
|
|
|
|
+ expect(blocks).toEqual(copy)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+// ── setBlockContent ───────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+describe("setBlockContent", () => {
|
|
|
|
|
+ it("updates content of the matching block", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [
|
|
|
|
|
+ block({ id: "a", content: "old" }),
|
|
|
|
|
+ block({ id: "b", content: "keep" }),
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = setBlockContent(blocks, "a", "new")
|
|
|
|
|
+ expect(result[0].content).toBe("new")
|
|
|
|
|
+ expect(result[1].content).toBe("keep")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("does not mutate the original array", () => {
|
|
|
|
|
+ const blocks: EditorBlock[] = [block({ id: "a", content: "x" })]
|
|
|
|
|
+ setBlockContent(blocks, "a", "y")
|
|
|
|
|
+ expect(blocks[0].content).toBe("x")
|
|
|
|
|
+ })
|
|
|
|
|
+})
|