ui-search-model.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. /**
  2. * The search palette and the trail, without a browser (CG-45).
  3. *
  4. * Two things here can be silently wrong rather than merely ugly. The palette's
  5. * flat item list must be exactly the concatenation of the sections it draws, or
  6. * ↑/↓/Enter follows a different row than the one under the highlight. And the
  7. * trail's wire format must round-trip, because it is the whole reason a walk
  8. * survives a reload or travels in a shared link.
  9. *
  10. * The geometry-free half of the same split as `ui-symbol-model.test.ts`.
  11. */
  12. import { describe, it, expect } from 'vitest';
  13. import {
  14. buildEntryPalette,
  15. buildSearchPalette,
  16. groupByKind,
  17. interleaveResults,
  18. kindGroupTitle,
  19. locationOf,
  20. moveSelection,
  21. parseFlowQuery,
  22. } from '../ui/src/lib/search-model';
  23. import { decodeTrail, encodeTrail, hopLabel, type TrailHop } from '../ui/src/lib/trail-codec';
  24. import type { WireEntryPoints, WireSearch, WireSearchResult } from '../ui/src/lib/api';
  25. /* ------------------------------------------------------------- fixtures -- */
  26. function result(over: Partial<WireSearchResult> = {}): WireSearchResult {
  27. return {
  28. id: over.id ?? `method:${over.name ?? 'load'}`,
  29. kind: 'method',
  30. name: 'load',
  31. qualifiedName: 'Service::load',
  32. file: 'src/service.ts',
  33. line: 42,
  34. endLine: 60,
  35. language: 'typescript',
  36. test: false,
  37. matchKind: 'exact',
  38. ...over,
  39. } as WireSearchResult;
  40. }
  41. function answer(items: WireSearchResult[]): WireSearch {
  42. return {
  43. query: 'q',
  44. text: 'q',
  45. filters: { kinds: [], languages: [], paths: [], names: [] },
  46. results: { total: items.length, shown: items.length, truncated: false, items },
  47. groups: [],
  48. };
  49. }
  50. /* ----------------------------------------------------------- flow query -- */
  51. describe('the flow grammar', () => {
  52. it('recognises the three shapes the placeholder advertises', () => {
  53. expect(parseFlowQuery('how does execute reach getFile')).toEqual({
  54. from: 'execute',
  55. to: 'getFile',
  56. });
  57. expect(parseFlowQuery('execute -> getFile')).toEqual({ from: 'execute', to: 'getFile' });
  58. expect(parseFlowQuery('execute → getFile')).toEqual({ from: 'execute', to: 'getFile' });
  59. expect(parseFlowQuery(' sync reaches indexFile? ')).toEqual({
  60. from: 'sync',
  61. to: 'indexFile',
  62. });
  63. });
  64. it('asks about the last segment of a qualified name', () => {
  65. // `Class.method` names the method; the class is how you say WHICH one, and
  66. // the search ranks that out on its own.
  67. expect(parseFlowQuery('how does CodeGraph.sync reach Cache.read')).toEqual({
  68. from: 'sync',
  69. to: 'read',
  70. });
  71. });
  72. it('leaves an ordinary search alone', () => {
  73. expect(parseFlowQuery('getImpactRadius')).toBeNull();
  74. expect(parseFlowQuery('kind:class Cache')).toBeNull();
  75. expect(parseFlowQuery('how does this work')).toBeNull();
  76. // A symbol reaching itself is not a path worth asking about.
  77. expect(parseFlowQuery('sync -> sync')).toBeNull();
  78. });
  79. });
  80. /* -------------------------------------------------------------- palette -- */
  81. describe('the palette', () => {
  82. it('flattens exactly what it draws, in draw order', () => {
  83. const palette = buildSearchPalette(
  84. [
  85. answer([
  86. result({ id: 'm1', name: 'load', kind: 'method' }),
  87. result({ id: 'f1', name: 'loader', kind: 'function' }),
  88. result({ id: 'm2', name: 'reload', kind: 'method' }),
  89. ]),
  90. ],
  91. null
  92. );
  93. // Groups appear where their best result did, so flattening reproduces the
  94. // ranking the keyboard walks.
  95. expect(palette.sections.map((s) => s.title)).toEqual(['Methods', 'Function']);
  96. expect(palette.items.map((i) => i.id)).toEqual(['m1', 'm2', 'f1']);
  97. expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
  98. expect(palette.empty).toBeNull();
  99. });
  100. it('says nothing matched instead of drawing an empty box', () => {
  101. const palette = buildSearchPalette([answer([])], null);
  102. expect(palette.items).toEqual([]);
  103. expect(palette.empty).toContain('No symbol or file');
  104. });
  105. it('interleaves a flow question so neither endpoint outranks the other', () => {
  106. const a = [result({ id: 'a1' }), result({ id: 'a2' })];
  107. const b = [result({ id: 'b1' }), result({ id: 'b2' })];
  108. expect(interleaveResults(a, b).map((r) => r.id)).toEqual(['a1', 'b1', 'a2', 'b2']);
  109. // A symbol that matched both halves keeps its earliest position.
  110. expect(interleaveResults(a, [result({ id: 'a2' })]).map((r) => r.id)).toEqual(['a1', 'a2']);
  111. });
  112. it('offers the flow FIRST for a flow question, then what each name matches', () => {
  113. const palette = buildSearchPalette(
  114. [answer([result({ id: 'a', name: 'sync' })]), answer([result({ id: 'b', name: 'read' })])],
  115. { from: 'sync', to: 'read' }
  116. );
  117. // First row, so Enter opens the path: the question asked for the path.
  118. expect(palette.sections[0]?.title).toBe('Flow');
  119. expect(palette.items[0]).toMatchObject({ type: 'flow', from: 'sync', to: 'read' });
  120. expect(palette.items.map((i) => i.id).slice(1)).toEqual(['a', 'b']);
  121. expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
  122. expect(palette.hint).toContain('sync');
  123. expect(palette.hint).toContain('read');
  124. });
  125. it('offers no flow row when the query is not a flow question', () => {
  126. const palette = buildSearchPalette([answer([result({ id: 'a' })])], null);
  127. expect(palette.sections.some((s) => s.title === 'Flow')).toBe(false);
  128. expect(palette.items.every((i) => i.type !== 'flow')).toBe(true);
  129. });
  130. it('names a kind bucket in sentence case, singular when there is one', () => {
  131. expect(kindGroupTitle('method', 3)).toBe('Methods');
  132. expect(kindGroupTitle('method', 1)).toBe('Method');
  133. expect(kindGroupTitle('type_alias', 2)).toBe('Type aliases');
  134. expect(kindGroupTitle('class', 2)).toBe('Classes');
  135. });
  136. it('locates a symbol by file and line, and a file by its directory', () => {
  137. expect(locationOf(result({ file: 'src/mcp/tools.ts', line: 412 }))).toBe('tools.ts:412');
  138. // The name column is already the basename; repeating the path says nothing.
  139. expect(
  140. locationOf(result({ kind: 'file', file: 'src/bin/codegraph.ts', name: 'codegraph.ts' }))
  141. ).toBe('src/bin');
  142. expect(locationOf(result({ kind: 'file', file: 'README.md', name: 'README.md' }))).toBe(
  143. 'project root'
  144. );
  145. });
  146. it('groups by kind without losing a row', () => {
  147. const results = [
  148. result({ id: '1', kind: 'class' }),
  149. result({ id: '2', kind: 'method' }),
  150. result({ id: '3', kind: 'class' }),
  151. ];
  152. const sections = groupByKind(results);
  153. expect(sections.map((s) => s.title)).toEqual(['Classes', 'Method']);
  154. expect(sections.flatMap((s) => s.items).map((i) => i.id)).toEqual(['1', '3', '2']);
  155. });
  156. it('wraps the selection at both ends', () => {
  157. expect(moveSelection(0, -1, 3)).toBe(2);
  158. expect(moveSelection(2, 1, 3)).toBe(0);
  159. expect(moveSelection(0, 1, 3)).toBe(1);
  160. // An empty list has one legal selection, and it is not -1.
  161. expect(moveSelection(0, 1, 0)).toBe(0);
  162. });
  163. });
  164. /* --------------------------------------------------------- entry points -- */
  165. function entryPoints(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
  166. return {
  167. frameworks: [],
  168. routes: {
  169. routed: false,
  170. routeCount: 0,
  171. items: { total: 0, shown: 0, truncated: false, items: [] },
  172. },
  173. tests: { total: 0, shown: 0, truncated: false, items: [] },
  174. index: { lastIndexedAt: null, files: 0 },
  175. timing: { elapsedMs: 0, cached: false },
  176. files: {
  177. total: 2,
  178. shown: 2,
  179. truncated: false,
  180. items: [
  181. {
  182. ...result({ id: 'file:src/bin/codegraph.ts', kind: 'file', name: 'codegraph.ts' }),
  183. file: 'src/bin/codegraph.ts',
  184. calls: 9,
  185. reaches: 37,
  186. dependents: 3,
  187. },
  188. ] as any,
  189. },
  190. hubs: {
  191. total: 1,
  192. shown: 1,
  193. truncated: false,
  194. items: [{ ...result({ id: 'method:get', name: 'get' }), dependents: 264 }] as any,
  195. },
  196. ...over,
  197. } as WireEntryPoints;
  198. }
  199. describe('the entry points', () => {
  200. it('says what each row is derived from, not that it IS the entry point', () => {
  201. const palette = buildEntryPalette(entryPoints());
  202. expect(palette.sections.map((s) => s.title)).toEqual([
  203. 'Files that run something',
  204. 'Most depended on',
  205. ]);
  206. expect(palette.sections[0]?.items[0]?.meta).toBe(
  207. '9 calls at module level · reaches 37 files'
  208. );
  209. expect(palette.sections[1]?.items[0]?.meta).toBe('264 dependents');
  210. expect(palette.items).toHaveLength(2);
  211. });
  212. it('puts routes first, and carries the id that makes a row clickable', () => {
  213. const palette = buildEntryPalette(
  214. entryPoints({
  215. routes: {
  216. routed: true,
  217. routeCount: 4,
  218. items: {
  219. total: 1,
  220. shown: 1,
  221. truncated: false,
  222. items: [
  223. {
  224. url: 'GET /users',
  225. method: 'GET',
  226. path: '/users',
  227. handler: 'listUsers',
  228. handlerKind: 'function',
  229. file: 'src/routes.ts',
  230. line: 11,
  231. handlerId: 'function:listUsers',
  232. routeFile: 'src/routes.ts',
  233. routeLine: 4,
  234. routeId: 'route:src/routes.ts:4:GET:/users',
  235. },
  236. ],
  237. },
  238. },
  239. })
  240. );
  241. expect(palette.sections[0]?.title).toBe('Routes');
  242. const row = palette.items[0];
  243. expect(row?.type).toBe('route');
  244. if (row?.type === 'route') {
  245. expect(row.url).toBe('GET /users');
  246. expect(row.nodeId).toBe('function:listUsers');
  247. expect(row.location).toBe('routes.ts:11');
  248. }
  249. });
  250. it('shortens each section for the panel under the box', () => {
  251. const many = entryPoints();
  252. (many.hubs.items as any) = Array.from({ length: 10 }, (_, i) => ({
  253. ...result({ id: `m${i}`, name: `hub${i}` }),
  254. dependents: 100 - i,
  255. }));
  256. expect(buildEntryPalette(many, { perSection: 3 }).items).toHaveLength(4);
  257. expect(buildEntryPalette(many).items).toHaveLength(11);
  258. });
  259. it('offers entry points under a typed query, BELOW the symbol matches', () => {
  260. const entries = entryPoints({
  261. routes: {
  262. routed: true,
  263. routeCount: 3,
  264. items: {
  265. total: 1,
  266. shown: 1,
  267. truncated: false,
  268. items: [
  269. {
  270. url: 'POST /users',
  271. method: 'POST',
  272. path: '/users',
  273. handler: 'createUser',
  274. handlerKind: 'function',
  275. file: 'src/handlers.ts',
  276. line: 8,
  277. handlerId: 'function:createUser',
  278. routeFile: 'src/routes.ts',
  279. routeLine: 4,
  280. routeId: 'route:src/routes.ts:4:POST:/users',
  281. },
  282. ],
  283. },
  284. },
  285. });
  286. const palette = buildSearchPalette(
  287. [answer([result({ id: 'class:Users', name: 'Users', kind: 'class' })])],
  288. null,
  289. { entries, query: 'users', entryRows: 6 }
  290. );
  291. // Symbol matches keep the top: someone typing a name asked for the name.
  292. expect(palette.sections[0]?.title).toBe('Class');
  293. const last = palette.sections[palette.sections.length - 1];
  294. expect(last?.title).toBe('Entry points');
  295. const row = last?.items[0];
  296. expect(row?.type).toBe('entry');
  297. // The row a plain search cannot produce: the URL WITH its handler.
  298. expect(row?.name).toBe('POST /users');
  299. expect(row?.meta).toBe('createUser · handlers.ts:8');
  300. expect(row?.location).toBe('route');
  301. // The keyboard's flat list still equals what is drawn.
  302. expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
  303. });
  304. it('does not repeat a symbol the search above already found', () => {
  305. const hub = { ...result({ id: 'method:get', name: 'get' }), dependents: 264 };
  306. const entries = entryPoints({
  307. hubs: { total: 1, shown: 1, truncated: false, items: [hub] as any },
  308. });
  309. const palette = buildSearchPalette([answer([result({ id: 'method:get', name: 'get' })])], null, {
  310. entries,
  311. query: 'get',
  312. entryRows: 6,
  313. });
  314. expect(palette.sections.map((s) => s.title)).not.toContain('Entry points');
  315. });
  316. it('draws nothing at all before the answer arrives', () => {
  317. const palette = buildEntryPalette(null);
  318. expect(palette.sections).toEqual([]);
  319. // Not an "empty" message: nothing is known yet, and saying "this index has
  320. // nothing" while the request is in flight would be a claim, not a state.
  321. expect(palette.empty).toBeNull();
  322. });
  323. });
  324. /* ----------------------------------------------------------------- trail -- */
  325. function hop(id: string, dir: TrailHop['dir']): TrailHop {
  326. return { id, name: null, kind: null, dir };
  327. }
  328. describe('the trail in the URL', () => {
  329. it('round-trips six hops with their directions intact', () => {
  330. const walked: TrailHop[] = [
  331. hop('method:a', 'start'),
  332. hop('method:b', 'down'),
  333. hop('method:c', 'down'),
  334. hop('method:d', 'up'),
  335. hop('method:e', 'down'),
  336. hop('file:src/bin/codegraph.ts', 'up'),
  337. ];
  338. const encoded = encodeTrail(walked);
  339. const decoded = decodeTrail(encoded);
  340. expect(decoded).toHaveLength(6);
  341. expect(decoded.map((h) => h.id)).toEqual(walked.map((h) => h.id));
  342. expect(decoded.map((h) => h.dir)).toEqual(['start', 'down', 'down', 'up', 'down', 'up']);
  343. // Re-encoding is byte-identical, which is what makes a shared link stable.
  344. expect(encodeTrail(decoded)).toBe(encoded);
  345. });
  346. it('keeps an id that begins with a direction letter', () => {
  347. // `union:…` and `default:…` start with 'u' and 'd'; an optional direction
  348. // prefix would swallow the first character of the id.
  349. const hops = [hop('union:Shape', 'start'), hop('declaration:x', 'down')];
  350. expect(decodeTrail(encodeTrail(hops)).map((h) => h.id)).toEqual([
  351. 'union:Shape',
  352. 'declaration:x',
  353. ]);
  354. });
  355. it('survives an id carrying the separator, and a hand-mangled param', () => {
  356. const hops = [hop('file:src/a,b.ts', 'start')];
  357. expect(decodeTrail(encodeTrail(hops))[0]?.id).toBe('file:src/a,b.ts');
  358. expect(decodeTrail(null)).toEqual([]);
  359. expect(decodeTrail('')).toEqual([]);
  360. // A token with no direction letter is dropped; a lone '%' would throw in
  361. // decodeURIComponent, so the raw text is kept instead — a hop that names
  362. // nothing is better than a trail that silently loses a position.
  363. expect(decodeTrail('x,,smethod%3Aa,d%')).toEqual([
  364. { id: 'method:a', name: null, kind: null, dir: 'start' },
  365. { id: '%', name: null, kind: null, dir: 'down' },
  366. ]);
  367. });
  368. it('labels an unresolved hop with something readable, never a raw hash', () => {
  369. expect(hopLabel({ ...hop('method:x', 'down'), name: 'load' })).toBe('load');
  370. expect(hopLabel(hop('file:src/bin/codegraph.ts', 'start'))).toBe('codegraph.ts');
  371. expect(hopLabel(hop('method:ada8ef1603fc03e3566eec72dc91138f', 'down'))).toBe('ada8ef16…');
  372. });
  373. });