1import type { FileDiffMetadata } from "@pierre/diffs";
2import { preloadFileTree } from "@pierre/trees";
3import { describe, expect, it } from "vite-plus/test";
4
5import {
6 buildDiffFileTreeUpdates,
7 compareDiffFileTreeEntries,
8 collectDirectoryPaths,
9 diffFileTreePositions,
10 diffFileTreeEntries,
11} from "./diffFileTree.logic";
12
13function file(type: FileDiffMetadata["type"], name: string, prevName = name): FileDiffMetadata {
14 return { type, name, prevName } as FileDiffMetadata;
15}
16
17describe("diffFileTreeEntries", () => {
18 it("maps each change type to its git status under the file's current path", () => {
19 expect(
20 diffFileTreeEntries([
21 file("new", "a/src/a.ts"),
22 file("deleted", "src/b.ts"),
23 file("rename-pure", "src/c.ts", "src/old-c.ts"),
24 file("rename-changed", "src/d.ts", "src/old-d.ts"),
25 file("change", "README.md"),
26 ]),
27 ).toEqual([
28 { path: "a/src/a.ts", status: "added" },
29 { path: "src/b.ts", status: "deleted" },
30 { path: "src/c.ts", status: "renamed" },
31 { path: "src/d.ts", status: "renamed" },
32 { path: "README.md", status: "modified" },
33 ]);
34 });
35});
36
37describe("diffFileTreeEntries", () => {
38 it("folds a file-to-symlink type change into one modified entry", () => {
39 expect(
40 diffFileTreeEntries([
41 file("change", "CLAUDE.md"),
42 file("deleted", "AGENTS.md"),
43 file("new", "AGENTS.md"),
44 file("new", "docs/new.md"),
45 ]),
46 ).toEqual([
47 { path: "CLAUDE.md", status: "modified" },
48 { path: "AGENTS.md", status: "modified" },
49 { path: "docs/new.md", status: "added" },
50 ]);
51 });
52});
53
54describe("collectDirectoryPaths", () => {
55 it("lists every ancestor once, parents first, with Pierre's trailing slash", () => {
56 expect(collectDirectoryPaths(["apps/web/src/a.ts", "apps/web/b.ts", "README.md"])).toEqual([
57 "apps/",
58 "apps/web/",
59 "apps/web/src/",
60 ]);
61 });
62});
63
64describe("diff tree reading order", () => {
65 it("places folders and files where their first diff appears", () => {
66 const paths = [
67 "apps/mobile/src/state/shell.ts",
68 "apps/mobile/src/features/threads/route.ts",
69 "apps/mobile/src/features/threads/screen.tsx",
70 ];
71 const positions = diffFileTreePositions(paths);
72 const tree = preloadFileTree({
73 paths,
74 initialExpansion: "open",
75 flattenEmptyDirectories: true,
76 sort: compareDiffFileTreeEntries(() => positions),
77 });
78 const rows = [...tree.shadowHtml.matchAll(/data-item-path="([^"]+)"/g)].map(
79 (match) => match[1],
80 );
81 expect(rows).toEqual([
82 "apps/mobile/src/",
83 "apps/mobile/src/state/",
84 "apps/mobile/src/state/shell.ts",
85 "apps/mobile/src/features/threads/",
86 "apps/mobile/src/features/threads/route.ts",
87 "apps/mobile/src/features/threads/screen.tsx",
88 ]);
89 });
90});
91
92describe("buildDiffFileTreeUpdates", () => {
93 it("adds a new file's directories before the file", () => {
94 expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([
95 { type: "add", path: "src/" },
96 { type: "add", path: "src/lib/" },
97 { type: "add", path: "src/lib/a.ts" },
98 ]);
99 });
100
101 it("removes files before their now-empty directories, deepest first", () => {
102 expect(buildDiffFileTreeUpdates(["src/lib/a.ts", "src/b.ts"], ["src/b.ts"])).toEqual([
103 { type: "remove", path: "src/lib/a.ts" },
104 { type: "remove", path: "src/lib/", recursive: true },
105 ]);
106 });
107
108 it("keeps a directory that still holds a file", () => {
109 expect(buildDiffFileTreeUpdates(["src/a.ts", "src/b.ts"], ["src/b.ts"])).toEqual([
110 { type: "remove", path: "src/a.ts" },
111 ]);
112 });
113
114 it("produces nothing when the paths are unchanged", () => {
115 expect(buildDiffFileTreeUpdates(["src/a.ts"], ["src/a.ts"])).toEqual([]);
116 });
117});