Blame · Line-by-line history
diff-view.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 82c3a38 | 1 | import { describe, it, expect } from "bun:test"; |
| 2 | import { parseDiff, pairLines } from "../lib/diff"; | |
| 3 | ||
| 4 | const SIMPLE_DIFF = `diff --git a/src/foo.ts b/src/foo.ts | |
| 5 | index abc..def 100644 | |
| 6 | --- a/src/foo.ts | |
| 7 | +++ b/src/foo.ts | |
| 8 | @@ -1,5 +1,5 @@ | |
| 9 | import { bar } from './bar'; | |
| 10 | -const x = 1; | |
| 11 | +const x = 2; | |
| 12 | export default x; | |
| 13 | `; | |
| 14 | ||
| 15 | const MULTIFILE_DIFF = `diff --git a/a.ts b/a.ts | |
| 16 | index 000..111 100644 | |
| 17 | --- a/a.ts | |
| 18 | +++ b/a.ts | |
| 19 | @@ -1,2 +1,3 @@ | |
| 20 | line1 | |
| 21 | +line2 | |
| 22 | line3 | |
| 23 | diff --git a/b.ts b/b.ts | |
| 24 | index 222..333 100644 | |
| 25 | --- a/b.ts | |
| 26 | +++ b/b.ts | |
| 27 | @@ -1,2 +1,1 @@ | |
| 28 | hello | |
| 29 | -world | |
| 30 | `; | |
| 31 | ||
| 32 | const BINARY_DIFF = `diff --git a/image.png b/image.png | |
| 33 | index abc..def 100644 | |
| 34 | Binary files a/image.png and b/image.png differ | |
| 35 | `; | |
| 36 | ||
| 37 | const WS_DIFF = `diff --git a/x.ts b/x.ts | |
| 38 | index 000..111 100644 | |
| 39 | --- a/x.ts | |
| 40 | +++ b/x.ts | |
| 41 | @@ -1,3 +1,3 @@ | |
| 42 | ctx | |
| 43 | - foo | |
| 44 | + foo | |
| 45 | ctx | |
| 46 | `; | |
| 47 | ||
| 48 | describe("parseDiff", () => { | |
| 49 | it("parses a single-file diff with one del and one add", () => { | |
| 50 | const files = parseDiff(SIMPLE_DIFF); | |
| 51 | expect(files.length).toBe(1); | |
| 52 | expect(files[0].path).toBe("src/foo.ts"); | |
| 53 | expect(files[0].additions).toBe(1); | |
| 54 | expect(files[0].deletions).toBe(1); | |
| 55 | expect(files[0].isBinary).toBe(false); | |
| 56 | }); | |
| 57 | ||
| 58 | it("assigns correct line numbers", () => { | |
| 59 | const files = parseDiff(SIMPLE_DIFF); | |
| 60 | const lines = files[0].lines; | |
| 61 | const hunk = lines.find((l) => l.type === "hunk"); | |
| 62 | expect(hunk).toBeTruthy(); | |
| 63 | const ctx1 = lines.find((l) => l.type === "ctx"); | |
| 64 | expect(ctx1?.oldLine).toBe(1); | |
| 65 | expect(ctx1?.newLine).toBe(1); | |
| 66 | const del = lines.find((l) => l.type === "del"); | |
| 67 | expect(del?.oldLine).toBe(2); | |
| 68 | expect(del?.newLine).toBeNull(); | |
| 69 | const add = lines.find((l) => l.type === "add"); | |
| 70 | expect(add?.oldLine).toBeNull(); | |
| 71 | expect(add?.newLine).toBe(2); | |
| 72 | }); | |
| 73 | ||
| 74 | it("parses multiple files", () => { | |
| 75 | const files = parseDiff(MULTIFILE_DIFF); | |
| 76 | expect(files.length).toBe(2); | |
| 77 | expect(files[0].path).toBe("a.ts"); | |
| 78 | expect(files[1].path).toBe("b.ts"); | |
| 79 | expect(files[0].additions).toBe(1); | |
| 80 | expect(files[0].deletions).toBe(0); | |
| 81 | expect(files[1].additions).toBe(0); | |
| 82 | expect(files[1].deletions).toBe(1); | |
| 83 | }); | |
| 84 | ||
| 85 | it("marks binary files", () => { | |
| 86 | const files = parseDiff(BINARY_DIFF); | |
| 87 | expect(files.length).toBe(1); | |
| 88 | expect(files[0].isBinary).toBe(true); | |
| 89 | expect(files[0].lines.length).toBe(0); | |
| 90 | }); | |
| 91 | ||
| 92 | it("detects whitespace-only changes", () => { | |
| 93 | const files = parseDiff(WS_DIFF); | |
| 94 | const lines = files[0].lines; | |
| 95 | const del = lines.find((l) => l.type === "del"); | |
| 96 | const add = lines.find((l) => l.type === "add"); | |
| 97 | expect(del?.wsOnly).toBe(true); | |
| 98 | expect(add?.wsOnly).toBe(true); | |
| 99 | }); | |
| 100 | ||
| 101 | it("does NOT mark non-whitespace changes as wsOnly", () => { | |
| 102 | const files = parseDiff(SIMPLE_DIFF); | |
| 103 | const del = files[0].lines.find((l) => l.type === "del"); | |
| 104 | const add = files[0].lines.find((l) => l.type === "add"); | |
| 105 | expect(del?.wsOnly).toBeFalsy(); | |
| 106 | expect(add?.wsOnly).toBeFalsy(); | |
| 107 | }); | |
| 108 | ||
| 109 | it("returns empty array for empty input", () => { | |
| 110 | expect(parseDiff("")).toEqual([]); | |
| 111 | }); | |
| 112 | ||
| 113 | it("handles CRLF line endings", () => { | |
| 114 | const crlf = SIMPLE_DIFF.replace(/\n/g, "\r\n"); | |
| 115 | const files = parseDiff(crlf); | |
| 116 | expect(files.length).toBe(1); | |
| 117 | expect(files[0].additions).toBe(1); | |
| 118 | }); | |
| 119 | ||
| 120 | it("strips the +/-/space prefix from content", () => { | |
| 121 | const files = parseDiff(SIMPLE_DIFF); | |
| 122 | const add = files[0].lines.find((l) => l.type === "add")!; | |
| 123 | const del = files[0].lines.find((l) => l.type === "del")!; | |
| 124 | expect(add.content).not.toMatch(/^\+/); | |
| 125 | expect(del.content).not.toMatch(/^-/); | |
| 126 | expect(add.content).toBe("const x = 2;"); | |
| 127 | expect(del.content).toBe("const x = 1;"); | |
| 128 | }); | |
| 129 | }); | |
| 130 | ||
| 131 | describe("pairLines", () => { | |
| 132 | it("pairs a del/add block into a single row", () => { | |
| 133 | const files = parseDiff(SIMPLE_DIFF); | |
| 134 | const rows = pairLines(files[0].lines); | |
| 135 | const paired = rows.find((r) => r.left?.type === "del" && r.right?.type === "add"); | |
| 136 | expect(paired).toBeTruthy(); | |
| 137 | }); | |
| 138 | ||
| 139 | it("passes context lines as both left and right", () => { | |
| 140 | const files = parseDiff(SIMPLE_DIFF); | |
| 141 | const rows = pairLines(files[0].lines); | |
| 142 | const ctx = rows.find((r) => r.left?.type === "ctx"); | |
| 143 | expect(ctx?.left).toBe(ctx?.right); | |
| 144 | }); | |
| 145 | ||
| 146 | it("pairs hunk headers as both left and right", () => { | |
| 147 | const files = parseDiff(SIMPLE_DIFF); | |
| 148 | const rows = pairLines(files[0].lines); | |
| 149 | const hunk = rows.find((r) => r.left?.type === "hunk"); | |
| 150 | expect(hunk?.left).toBe(hunk?.right); | |
| 151 | }); | |
| 152 | ||
| 153 | it("creates null left for orphan add lines", () => { | |
| 154 | const files = parseDiff(MULTIFILE_DIFF); | |
| 155 | const rows = pairLines(files[0].lines); | |
| 156 | const orphanAdd = rows.find((r) => r.left === null && r.right?.type === "add"); | |
| 157 | expect(orphanAdd).toBeTruthy(); | |
| 158 | }); | |
| 159 | ||
| 160 | it("creates null right for orphan del lines", () => { | |
| 161 | const files = parseDiff(MULTIFILE_DIFF); | |
| 162 | const rows = pairLines(files[1].lines); | |
| 163 | const orphanDel = rows.find((r) => r.right === null && r.left?.type === "del"); | |
| 164 | expect(orphanDel).toBeTruthy(); | |
| 165 | }); | |
| 166 | ||
| 167 | it("total row count equals unique line count (not doubled)", () => { | |
| 168 | const files = parseDiff(SIMPLE_DIFF); | |
| 169 | const rows = pairLines(files[0].lines); | |
| 170 | // 1 hunk + 1 ctx + 1 paired del/add + 1 ctx = 4 rows | |
| 171 | expect(rows.length).toBe(4); | |
| 172 | // source has 5 lines: hunk, ctx, del, add, ctx | |
| 173 | expect(files[0].lines.length).toBe(5); | |
| 174 | }); | |
| 175 | }); |