Public: anyone can read this; the crew can change it.

A test pins the terminal's file finder: every file in the revision, one read, and the query narrows it #7002

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6981-a-test-pins-the-terminal-s-file-finder-e → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/6981-a-test-pins-the-terminal-s-file-finder-e was merged into main as 0844b2c.

Review

Sign in to review this change.
1 file changed +246 −0
test/tui-finder.test.ts added +246
@@ -0,0 +1,246 @@
1 /**
2 * The terminal's file finder (`src/tui/screens/finder.ts`): every file in a
3 * revision from one `RepoSource.paths` read, narrowed in memory by the query.
4 *
5 * The screen is mounted over a plain root in a headless App, fed a real
6 * `RepoSource` from `sourceFor` with `paths` counted.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { createApp, type App as Forge } from "../src/app.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { App, type Ctx, type Screen } from "../src/tui/app/app.ts";
17 import { finderScreen } from "../src/tui/screens/finder.ts";
18 import type { Session } from "../src/tui/session.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { err } from "../src/kernel/result.ts";
21 import { sourceFor, type RepoSource } from "../src/view/source.ts";
22 import { seedAdmin } from "./helpers/admin.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24 import { settleApp } from "./helpers/tui.ts";
25
26 quietLogs();
27
28 describe("the terminal's file finder", () => {
29 let dataDir: string;
30 let forge: Forge;
31 let alice: User;
32 let bob: User;
33
34 beforeAll(async () => {
35 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-finder-"));
36 forge = createApp({ dataDir, port: 0 });
37 const a = await seedAdmin(forge, {
38 username: "alice",
39 email: "[email protected]",
40 password: "a-long-passphrase",
41 });
42 const b = await createUser(forge.db, {
43 username: "bob",
44 email: "[email protected]",
45 password: "a-long-passphrase",
46 origin: "seed",
47 trusted: true,
48 });
49 if (!a.ok || !b.ok) throw new Error("signup failed");
50 alice = a.value;
51 bob = b.value;
52
53 for (const [name, visibility] of [
54 ["pharos", "public"],
55 ["vault", "private"],
56 ] as const) {
57 const made = await createRepo(forge, alice, { name, visibility });
58 if (!made.ok) throw new Error("repo failed");
59 await commitToBranch(forge, alice, made.value, {
60 message: "seed",
61 files: [
62 { path: "README.md", content: "# hi\n" },
63 { path: "src/main.ts", content: "export const one = 1;\n" },
64 { path: "src/deep/nested/lamp.ts", content: "export const l = 1;\n" },
65 { path: "docs/DESIGN.md", content: "# Design\n" },
66 { path: "main/notes.md", content: "notes\n" },
67 ],
68 });
69 await commitToBranch(forge, alice, made.value, {
70 branch: "feature",
71 message: "add two",
72 files: [{ path: "src/two.ts", content: "export const two = 2;\n" }],
73 });
74 }
75 });
76
77 afterAll(async () => {
78 forge.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 function open(
83 actor: User | undefined,
84 name: string,
85 ref: string,
86 override?: RepoSource,
87 ) {
88 const made = sourceFor(forge, actor, { owner: "alice", name });
89 const calls: string[] = [];
90 const picked: string[] = [];
91 let src: RepoSource | undefined = override;
92 if (made.ok && override === undefined) {
93 const real = made.value;
94 src = { ...real, paths: (r: string) => (calls.push(r), real.paths(r)) };
95 }
96 const terminal = headlessTerminal(100, 30);
97 let ctx!: Ctx;
98 let root!: Screen;
99 const app = new App({
100 terminal,
101 theme: "dark",
102 root: (c) => {
103 ctx = c;
104 root = {
105 title: "Root",
106 keys: [],
107 render() {},
108 key: () => false,
109 };
110 return root;
111 },
112 });
113 app.start();
114 return {
115 made,
116 calls,
117 picked,
118 app,
119 text: () => app.screen.toText(),
120 async show(r = ref) {
121 ctx.push(
122 finderScreen({} as Session, ctx, root, src!, r, (p) =>
123 picked.push(p),
124 ),
125 );
126 await settleApp(app);
127 },
128 async press(...keys: string[]) {
129 for (const key of keys)
130 app.dispatch({
131 type: "key",
132 key,
133 ctrl: false,
134 alt: false,
135 shift: false,
136 });
137 await settleApp(app);
138 },
139 };
140 }
141
outdated

The comment says the basename hit outranks the directory-only one, but lamp has a single match, so nothing asserts ranking. Use a query that matches several rows and compare their order.

142 test("lists every nested file; a query narrows the list", async () => {
143 const h = open(alice, "pharos", "main");
144 try {
145 await h.show();
146 expect(h.text()).toContain("5 files");
147 for (const p of [
148 "README.md",
149 "src/main.ts",
150 "src/deep/nested/lamp.ts",
151 "docs/DESIGN.md",
152 "main/notes.md",
153 ])
154 expect(h.text()).toContain(p);
155
156 await h.press(..."lamp");
157 expect(h.text()).toContain("src/deep/nested/lamp.ts");
outdated

This test duplicates the Enter pick in the first test: srcmn matches one file, so 'best match' is never exercised. Make it use a multi-match query and expect the top-ranked path.

158 expect(h.text()).not.toContain("README.md");
159 expect(h.text()).not.toContain("docs/DESIGN.md");
160 } finally {
161 h.app.stop();
162 }
163 });
164
165 test("a query matching several files ranks the basename hit first, and enter opens the top row", async () => {
166 const h = open(alice, "pharos", "main");
167 try {
168 await h.show();
169 await h.press(..."main");
170 const text = h.text();
171 // Listing order is alphabetical, so `main/notes.md` (a directory-only
172 // hit) would come first if nothing ranked.
173 expect(text).toContain("main/notes.md");
174 expect(text.indexOf("src/main.ts")).toBeGreaterThan(-1);
175 expect(text.indexOf("src/main.ts")).toBeLessThan(
176 text.indexOf("main/notes.md"),
177 );
178 await h.press("enter");
179 expect(h.picked).toEqual(["src/main.ts"]);
180 } finally {
181 h.app.stop();
182 }
183 });
184
185 test("another ref lists that ref's files", async () => {
186 const h = open(alice, "pharos", "feature");
187 try {
188 await h.show("feature");
189 expect(h.text()).toContain("6 files");
190 expect(h.text()).toContain("src/two.ts");
191 expect(h.calls).toEqual(["feature"]);
192 } finally {
193 h.app.stop();
194 }
195 const main = open(alice, "pharos", "main");
196 try {
197 await main.show();
198 expect(main.text()).not.toContain("src/two.ts");
199 } finally {
200 main.app.stop();
201 }
202 });
203
204 test("typing, deleting and moving never read paths again", async () => {
205 const h = open(alice, "pharos", "main");
outdated

This only tests sourceFor's refusal. Add a guest actor, and either rename the test or cover the finder's own failure path (the cannot list files toast).

206 try {
207 await h.show();
208 expect(h.calls).toEqual(["main"]);
209 await h.press(..."src", "down", "backspace", "m", "a", "i", "n");
210 expect(h.calls).toEqual(["main"]);
211 } finally {
212 h.app.stop();
213 }
214 });
215
216 test("the gate is the source's: a stranger and a guest get no source to list, the owner does", async () => {
217 expect(open(bob, "vault", "main").made.ok).toBe(false);
218 expect(open(undefined, "vault", "main").made.ok).toBe(false);
219 const h = open(alice, "vault", "main");
220 try {
221 expect(h.made.ok).toBe(true);
222 await h.show();
223 expect(h.text()).toContain("5 files");
224 } finally {
225 h.app.stop();
226 }
227 });
228
229 test("a listing that fails says so and lists nothing", async () => {
230 const real = sourceFor(forge, alice, { owner: "alice", name: "pharos" });
231 if (!real.ok) throw new Error("source failed");
232 const refused: RepoSource = {
233 ...real.value,
234 paths: async () => err("not_found"),
235 };
236 const h = open(alice, "pharos", "main", refused);
237 try {
238 await h.show();
239 expect(h.text()).toContain("cannot list files");
240 expect(h.text()).not.toContain("README.md");
241 expect(h.text()).toContain("0 files");
242 } finally {
243 h.app.stop();
244 }
245 });
246 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs