import { describe, it, expect } from "vitest"; import { parsePatchFiles } from "@pierre/diffs"; import type { FileDiffMetadata, DiffLineAnnotation } from "@pierre/diffs"; import { buildHunkAnnotations, buildWalkthroughSelectedLines, type HunkOutputs, } from "./use-diff-viewer-state"; import type { RevertBlockInfo } from "./diff-viewer"; import type { AnnotationMetadata } from "./use-diff-annotation-renderer"; const FILE = "src/foo.ts"; const DIFF_HEADER = [`diff --git a/${FILE} b/${FILE}`, `--- a/${FILE}`, `+++ b/${FILE}`]; function freshOut(): HunkOutputs { return { result: [] as DiffLineAnnotation[], lineMap: new Map(), revertMap: new Map(), }; } function parseSinglePatch(diff: string): FileDiffMetadata { const patches = parsePatchFiles(diff); const file = patches[0]?.files[0]; if (!file) throw new Error("parsePatchFiles returned no files"); return file; } describe("buildHunkAnnotations", () => { it("reconstructs revert oldLines by slicing deletionLines via deletionLineIndex", () => { // Mixed change: 1 context, 2 deletions, 2 additions, 1 trailing context. const diff = [ ...DIFF_HEADER, "@@ -1,5 +1,5 @@", " keep", "-remove one", "-remove two", "+add one", "+add two", " end", "", ].join("\n"); const out = freshOut(); buildHunkAnnotations(parseSinglePatch(diff), out); expect(out.revertMap.size).toBe(1); const info = [...out.revertMap.values()][0]; // addStart begins after the leading 1 line of context, so line 2. expect(info.addStart).toBe(2); expect(info.addCount).toBe(2); // The library hands us *line text* via deletionLines + deletionLineIndex; // the hunk-walker is responsible for picking the right slice and stripping // trailing newlines. If the addressing scheme drifts in a future bump this // assertion will fail rather than silently delivering wrong "old lines". expect(info.oldLines).toEqual(["remove one", "remove two"]); }); it("emits a hunk-actions annotation anchored at the previous context line", () => { const diff = [...DIFF_HEADER, "@@ -1,3 +1,3 @@", " keep", "-old", "+new", " tail", ""].join( "\n", ); const out = freshOut(); buildHunkAnnotations(parseSinglePatch(diff), out); expect(out.result).toHaveLength(1); const ann = out.result[0]; // additions side because aLen > 0; anchored on the previous context line (line 1). expect(ann.side).toBe("additions"); expect(ann.lineNumber).toBe(1); expect(ann.metadata).toMatchObject({ type: "hunk-actions" }); }); it("maps each added/deleted line to its changeBlock id in lineMap", () => { const diff = [...DIFF_HEADER, "@@ -1,3 +1,3 @@", " keep", "-old", "+new", " tail", ""].join( "\n", ); const out = freshOut(); buildHunkAnnotations(parseSinglePatch(diff), out); const cbId = [...out.revertMap.keys()][0]; // Line 2 is the modified line on both sides (after the 1-line context prefix). expect(out.lineMap.get("additions:2")).toBe(cbId); expect(out.lineMap.get("deletions:2")).toBe(cbId); }); it("handles a pure-deletion change (aLen === 0): empty addCount, side='deletions'", () => { const diff = [...DIFF_HEADER, "@@ -1,3 +1,2 @@", " keep", "-removed", " tail", ""].join("\n"); const out = freshOut(); buildHunkAnnotations(parseSinglePatch(diff), out); expect(out.revertMap.size).toBe(1); const info = [...out.revertMap.values()][0]; expect(info.addCount).toBe(0); expect(info.oldLines).toEqual(["removed"]); expect(out.result[0].side).toBe("deletions"); }); it("handles a pure-addition change (dLen === 0): empty oldLines, side='additions'", () => { const diff = [...DIFF_HEADER, "@@ -1,2 +1,3 @@", " keep", "+added", " tail", ""].join("\n"); const out = freshOut(); buildHunkAnnotations(parseSinglePatch(diff), out); expect(out.revertMap.size).toBe(1); const info = [...out.revertMap.values()][0]; expect(info.addCount).toBe(1); expect(info.oldLines).toEqual([]); expect(out.result[0].side).toBe("additions"); }); }); const WALKTHROUGH_PATH = "apps/web/main.ts"; describe("buildWalkthroughSelectedLines", () => { it("returns the active walkthrough line range for a matching file", () => { expect( buildWalkthroughSelectedLines( { path: WALKTHROUGH_PATH, repository_name: "frontend" }, { file: "main.ts", repo: "frontend", line: 10, line_end: 12, text: "explain" }, ), ).toEqual({ side: "additions", start: 10, end: 12 }); }); it("returns null for mismatched repos", () => { expect( buildWalkthroughSelectedLines( { path: WALKTHROUGH_PATH, repository_name: "backend" }, { file: "main.ts", repo: "frontend", line: 10, text: "explain" }, ), ).toBeNull(); }); it("defaults line_end to line when the walkthrough step has no line_end", () => { expect( buildWalkthroughSelectedLines( { path: WALKTHROUGH_PATH, repository_name: "frontend" }, { file: "main.ts", repo: "frontend", line: 15, text: "see this" }, ), ).toEqual({ side: "additions", start: 15, end: 15 }); }); it("returns null when walkthrough annotations are disabled", () => { expect( buildWalkthroughSelectedLines( { path: WALKTHROUGH_PATH, repository_name: "frontend" }, { file: "main.ts", repo: "frontend", line: 15, text: "see this" }, false, ), ).toBeNull(); }); });