|
@@ -0,0 +1,327 @@
|
|
|
|
|
+import { beforeEach, describe, expect, it } from "vitest"
|
|
|
|
|
+import {
|
|
|
|
|
+ type EditorBlock,
|
|
|
|
|
+ serializeBlocks,
|
|
|
|
|
+ splitMarkdownIntoBlocks,
|
|
|
|
|
+} from "@/lib/block-parser"
|
|
|
|
|
+
|
|
|
|
|
+// Deterministic ID generator for testing.
|
|
|
|
|
+let nextId = 0
|
|
|
|
|
+function testId(): string {
|
|
|
|
|
+ return String(nextId++)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+beforeEach(() => {
|
|
|
|
|
+ nextId = 0
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+function parse(md: string): EditorBlock[] {
|
|
|
|
|
+ return splitMarkdownIntoBlocks(md, testId)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+describe("splitMarkdownIntoBlocks", () => {
|
|
|
|
|
+ it("single block", () => {
|
|
|
|
|
+ const result = parse("* TODO my item")
|
|
|
|
|
+ expect(result).toEqual([{ id: "0", depth: 0, content: "TODO my item" }])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("multiple blocks", () => {
|
|
|
|
|
+ const result = parse("* Item one\n* Item two")
|
|
|
|
|
+ expect(result).toEqual([
|
|
|
|
|
+ { id: "0", depth: 0, content: "Item one" },
|
|
|
|
|
+ { id: "1", depth: 0, content: "Item two" },
|
|
|
|
|
+ ])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("nested blocks", () => {
|
|
|
|
|
+ const result = parse("* Root\n * Child\n* Another")
|
|
|
|
|
+ expect(result).toEqual([
|
|
|
|
|
+ { id: "0", depth: 0, content: "Root" },
|
|
|
|
|
+ { id: "1", depth: 1, content: "Child" },
|
|
|
|
|
+ { id: "2", depth: 0, content: "Another" },
|
|
|
|
|
+ ])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("deep nesting", () => {
|
|
|
|
|
+ const result = parse("* A\n * B\n * C\n * D\n* E")
|
|
|
|
|
+ expect(result).toEqual([
|
|
|
|
|
+ { id: "0", depth: 0, content: "A" },
|
|
|
|
|
+ { id: "1", depth: 1, content: "B" },
|
|
|
|
|
+ { id: "2", depth: 2, content: "C" },
|
|
|
|
|
+ { id: "3", depth: 1, content: "D" },
|
|
|
|
|
+ { id: "4", depth: 0, content: "E" },
|
|
|
|
|
+ ])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("continuation lines without blank lines", () => {
|
|
|
|
|
+ const result = parse("* Item with\n continuation")
|
|
|
|
|
+ expect(result).toHaveLength(1)
|
|
|
|
|
+ expect(result[0].content).toBe("Item with\n continuation")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("continuation lines preserve indentation", () => {
|
|
|
|
|
+ const result = parse("* TODO my item\n gotta do the thing")
|
|
|
|
|
+ expect(result).toHaveLength(1)
|
|
|
|
|
+ expect(result[0].content).toBe("TODO my item\n gotta do the thing")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("code fence inside a block", () => {
|
|
|
|
|
+ const result = parse(
|
|
|
|
|
+ "* A note\n ```ts\n const x = 1\n const y = 2\n ```",
|
|
|
|
|
+ )
|
|
|
|
|
+ expect(result).toHaveLength(1)
|
|
|
|
|
+ expect(result[0].depth).toBe(0)
|
|
|
|
|
+ expect(result[0].content).toContain("```ts")
|
|
|
|
|
+ expect(result[0].content).toContain("const x = 1")
|
|
|
|
|
+ expect(result[0].content).toContain("const y = 2")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("blank line inside fence inside a block", () => {
|
|
|
|
|
+ const md = "* A note\n ```ts\n const x = 1\n\n const y = 2\n ```"
|
|
|
|
|
+ const result = parse(md)
|
|
|
|
|
+ expect(result).toHaveLength(1)
|
|
|
|
|
+ expect(result[0].content).toContain("\n\n")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("two fences in one block", () => {
|
|
|
|
|
+ const md =
|
|
|
|
|
+ "* Thoughts\n ```ts\n const x = 1\n ```\n and also\n ```css\n .a { color: red }\n ```"
|
|
|
|
|
+ const result = parse(md)
|
|
|
|
|
+ expect(result).toHaveLength(1)
|
|
|
|
|
+ expect(result[0].content).toMatch(/```ts/)
|
|
|
|
|
+ expect(result[0].content).toMatch(/```css/)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("tilde fence inside a block", () => {
|
|
|
|
|
+ const result = parse("* Using tilde\n ~~~\n code\n ~~~")
|
|
|
|
|
+ expect(result).toHaveLength(1)
|
|
|
|
|
+ expect(result[0].content).toContain("~~~")
|
|
|
|
|
+ expect(result[0].content).toContain("code")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("nested item with fence", () => {
|
|
|
|
|
+ const result = parse(
|
|
|
|
|
+ "* Root\n * Child with\n ```py\n pass\n ```\n* Another",
|
|
|
|
|
+ )
|
|
|
|
|
+ expect(result).toHaveLength(3)
|
|
|
|
|
+ expect(result[0]).toEqual({
|
|
|
|
|
+ id: "0",
|
|
|
|
|
+ depth: 0,
|
|
|
|
|
+ content: "Root",
|
|
|
|
|
+ })
|
|
|
|
|
+ expect(result[1].depth).toBe(1)
|
|
|
|
|
+ expect(result[1].content).toContain("Child with")
|
|
|
|
|
+ expect(result[1].content).toContain("```py")
|
|
|
|
|
+ expect(result[2]).toEqual({ id: "2", depth: 0, content: "Another" })
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("multiple blank lines between items", () => {
|
|
|
|
|
+ const md = "* One\n\n\n* Two"
|
|
|
|
|
+ const result = parse(md)
|
|
|
|
|
+ expect(result).toEqual([
|
|
|
|
|
+ { id: "0", depth: 0, content: "One" },
|
|
|
|
|
+ { id: "1", depth: 0, content: "Two" },
|
|
|
|
|
+ ])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("blank line between items", () => {
|
|
|
|
|
+ const md = "* One\n\n* Two"
|
|
|
|
|
+ const result = parse(md)
|
|
|
|
|
+ expect(result).toEqual([
|
|
|
|
|
+ { id: "0", depth: 0, content: "One" },
|
|
|
|
|
+ { id: "1", depth: 0, content: "Two" },
|
|
|
|
|
+ ])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("blank line between non-fence content splits list item (GFM behavior)", () => {
|
|
|
|
|
+ // In GFM, a blank line between non-fence content within a list item
|
|
|
|
|
+ // terminates the item. "Continuation" becomes a standalone paragraph
|
|
|
|
|
+ // outside the list and is NOT captured as a block.
|
|
|
|
|
+ const result = parse("* One\n\nContinuation\n* Two")
|
|
|
|
|
+ expect(result).toHaveLength(2)
|
|
|
|
|
+ expect(result[0].content).toBe("One")
|
|
|
|
|
+ expect(result[1].content).toBe("Two")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("empty block between non-empty blocks", () => {
|
|
|
|
|
+ const result = parse("* \n* Item")
|
|
|
|
|
+ expect(result).toHaveLength(2)
|
|
|
|
|
+ expect(result[0].content).toBe("")
|
|
|
|
|
+ expect(result[1].content).toBe("Item")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("task state in block content", () => {
|
|
|
|
|
+ const result = parse("* TODO my first item\n* DONE already completed")
|
|
|
|
|
+ expect(result[0].content).toBe("TODO my first item")
|
|
|
|
|
+ expect(result[1].content).toBe("DONE already completed")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("block with inline math", () => {
|
|
|
|
|
+ const result = parse("* Solve $x^2 + y^2 = z^2$")
|
|
|
|
|
+ expect(result[0].content).toContain("$x^2 + y^2 = z^2$")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("empty document", () => {
|
|
|
|
|
+ expect(parse("")).toEqual([])
|
|
|
|
|
+ expect(parse(" ")).toEqual([])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("empty block", () => {
|
|
|
|
|
+ const result = parse("* ")
|
|
|
|
|
+ expect(result).toHaveLength(1)
|
|
|
|
|
+ expect(result[0].content).toBe("")
|
|
|
|
|
+ expect(result[0].depth).toBe(0)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("single newline only", () => {
|
|
|
|
|
+ expect(parse("\n")).toEqual([])
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("block with leading whitespace in content", () => {
|
|
|
|
|
+ // Content after "* " should preserve its own leading whitespace
|
|
|
|
|
+ const result = parse("* indented content")
|
|
|
|
|
+ expect(result[0].content).toBe(" indented content")
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe("ID preservation", () => {
|
|
|
|
|
+ it("preserves IDs by position when existing blocks provided", () => {
|
|
|
|
|
+ const existing: EditorBlock[] = [
|
|
|
|
|
+ { id: "keep-a", depth: 0, content: "old one" },
|
|
|
|
|
+ { id: "keep-b", depth: 0, content: "old two" },
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = splitMarkdownIntoBlocks(
|
|
|
|
|
+ "* new one\n* new two",
|
|
|
|
|
+ testId,
|
|
|
|
|
+ existing,
|
|
|
|
|
+ )
|
|
|
|
|
+ expect(result).toHaveLength(2)
|
|
|
|
|
+ expect(result[0].id).toBe("keep-a")
|
|
|
|
|
+ expect(result[1].id).toBe("keep-b")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("generates new IDs for surplus blocks beyond existing", () => {
|
|
|
|
|
+ const existing: EditorBlock[] = [{ id: "keep-a", depth: 0, content: "old" }]
|
|
|
|
|
+ const result = splitMarkdownIntoBlocks(
|
|
|
|
|
+ "* one\n* two\n* three",
|
|
|
|
|
+ testId,
|
|
|
|
|
+ existing,
|
|
|
|
|
+ )
|
|
|
|
|
+ expect(result).toHaveLength(3)
|
|
|
|
|
+ expect(result[0].id).toBe("keep-a")
|
|
|
|
|
+ expect(result[1].id).toBe("0") // new, from testId counter
|
|
|
|
|
+ expect(result[2].id).toBe("1")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("ignores existing IDs beyond parsed block count", () => {
|
|
|
|
|
+ // When there are fewer blocks after re-parse, the extra existing IDs are dropped
|
|
|
|
|
+ const existing: EditorBlock[] = [
|
|
|
|
|
+ { id: "keep-a", depth: 0, content: "old one" },
|
|
|
|
|
+ { id: "dropped-b", depth: 0, content: "old two" },
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = splitMarkdownIntoBlocks("* only one", testId, existing)
|
|
|
|
|
+ expect(result).toHaveLength(1)
|
|
|
|
|
+ expect(result[0].id).toBe("keep-a")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("content and depth are always from the parsed input, not existing", () => {
|
|
|
|
|
+ const existing: EditorBlock[] = [
|
|
|
|
|
+ { id: "keep", depth: 99, content: "old content" },
|
|
|
|
|
+ ]
|
|
|
|
|
+ const result = splitMarkdownIntoBlocks("* new content", testId, existing)
|
|
|
|
|
+ expect(result).toHaveLength(1)
|
|
|
|
|
+ expect(result[0].id).toBe("keep")
|
|
|
|
|
+ expect(result[0].content).toBe("new content")
|
|
|
|
|
+ expect(result[0].depth).toBe(0)
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe("serializeBlocks", () => {
|
|
|
|
|
+ it("single block", () => {
|
|
|
|
|
+ const md = serializeBlocks([{ id: "0", depth: 0, content: "TODO my item" }])
|
|
|
|
|
+ expect(md).toBe("* TODO my item")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("multiple blocks", () => {
|
|
|
|
|
+ const md = serializeBlocks([
|
|
|
|
|
+ { id: "0", depth: 0, content: "Item one" },
|
|
|
|
|
+ { id: "1", depth: 0, content: "Item two" },
|
|
|
|
|
+ ])
|
|
|
|
|
+ expect(md).toBe("* Item one\n* Item two")
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("nested blocks", () => {
|
|
|
|
|
+ const md = serializeBlocks([
|
|
|
|
|
+ { id: "0", depth: 0, content: "Root" },
|
|
|
|
|
+ { id: "1", depth: 1, content: "Child" },
|
|
|
|
|
+ { id: "2", depth: 0, content: "Another" },
|
|
|
|
|
+ ])
|
|
|
|
|
+ expect(md).toBe("* Root\n * Child\n* Another")
|
|
|
|
|
+ })
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+describe("round-trip", () => {
|
|
|
|
|
+ it("lossless for simple blocks", () => {
|
|
|
|
|
+ const md = "* Item one\n* Item two"
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lossless for nested blocks", () => {
|
|
|
|
|
+ const md = "* Root\n * Child\n * Grandchild\n * Another\n* End"
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lossless for block with code fence", () => {
|
|
|
|
|
+ const md = "* A note\n ```ts\n const x = 1\n ```"
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lossless for continuation lines", () => {
|
|
|
|
|
+ const md = "* Item with\n continuation"
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lossless for blank lines inside fence", () => {
|
|
|
|
|
+ const md = "* A note\n ```ts\n const x = 1\n\n const y = 2\n ```"
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lossless for two fences in one block", () => {
|
|
|
|
|
+ const md =
|
|
|
|
|
+ "* Thoughts\n ```ts\n const x = 1\n ```\n and also\n ```css\n .a { color: red }\n ```"
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lossless for tilde fence", () => {
|
|
|
|
|
+ const md = "* Using tilde\n ~~~\n code\n ~~~"
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lossless for nested item with fence", () => {
|
|
|
|
|
+ const md = "* Root\n * Child with\n ```py\n pass\n ```\n* Another"
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lossless for task states", () => {
|
|
|
|
|
+ const md = "* TODO first\n* DONE second\n* LATER third"
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("lossless for empty blocks", () => {
|
|
|
|
|
+ const md = "* \n* Item\n* "
|
|
|
|
|
+ expect(serializeBlocks(parse(md))).toBe(md)
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ it("blank lines between items are normalized away on serialize", () => {
|
|
|
|
|
+ // Blank lines between items are consumed by the parser — they
|
|
|
|
|
+ // don't affect block content. The re-serialized output normalizes
|
|
|
|
|
+ // them away, which is intentional.
|
|
|
|
|
+ const md = "* One\n\n* Two"
|
|
|
|
|
+ const result = parse(md)
|
|
|
|
|
+ expect(result).toEqual([
|
|
|
|
|
+ { id: "0", depth: 0, content: "One" },
|
|
|
|
|
+ { id: "1", depth: 0, content: "Two" },
|
|
|
|
|
+ ])
|
|
|
|
|
+ expect(serializeBlocks(result)).toBe("* One\n* Two")
|
|
|
|
|
+ })
|
|
|
|
|
+})
|