ui-search-model.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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('explains that a flow question is answered by both endpoints for now', () => {
  113. const palette = buildSearchPalette(
  114. [answer([result({ id: 'a', name: 'sync' })]), answer([result({ id: 'b', name: 'read' })])],
  115. { from: 'sync', to: 'read' }
  116. );
  117. expect(palette.items.map((i) => i.id)).toEqual(['a', 'b']);
  118. expect(palette.hint).toContain('sync');
  119. expect(palette.hint).toContain('read');
  120. });
  121. it('names a kind bucket in sentence case, singular when there is one', () => {
  122. expect(kindGroupTitle('method', 3)).toBe('Methods');
  123. expect(kindGroupTitle('method', 1)).toBe('Method');
  124. expect(kindGroupTitle('type_alias', 2)).toBe('Type aliases');
  125. expect(kindGroupTitle('class', 2)).toBe('Classes');
  126. });
  127. it('locates a symbol by file and line, and a file by its directory', () => {
  128. expect(locationOf(result({ file: 'src/mcp/tools.ts', line: 412 }))).toBe('tools.ts:412');
  129. // The name column is already the basename; repeating the path says nothing.
  130. expect(
  131. locationOf(result({ kind: 'file', file: 'src/bin/codegraph.ts', name: 'codegraph.ts' }))
  132. ).toBe('src/bin');
  133. expect(locationOf(result({ kind: 'file', file: 'README.md', name: 'README.md' }))).toBe(
  134. 'project root'
  135. );
  136. });
  137. it('groups by kind without losing a row', () => {
  138. const results = [
  139. result({ id: '1', kind: 'class' }),
  140. result({ id: '2', kind: 'method' }),
  141. result({ id: '3', kind: 'class' }),
  142. ];
  143. const sections = groupByKind(results);
  144. expect(sections.map((s) => s.title)).toEqual(['Classes', 'Method']);
  145. expect(sections.flatMap((s) => s.items).map((i) => i.id)).toEqual(['1', '3', '2']);
  146. });
  147. it('wraps the selection at both ends', () => {
  148. expect(moveSelection(0, -1, 3)).toBe(2);
  149. expect(moveSelection(2, 1, 3)).toBe(0);
  150. expect(moveSelection(0, 1, 3)).toBe(1);
  151. // An empty list has one legal selection, and it is not -1.
  152. expect(moveSelection(0, 1, 0)).toBe(0);
  153. });
  154. });
  155. /* --------------------------------------------------------- entry points -- */
  156. function entryPoints(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
  157. return {
  158. routes: { routed: false, routeCount: 0, items: [] },
  159. files: {
  160. total: 2,
  161. shown: 2,
  162. truncated: false,
  163. items: [
  164. {
  165. ...result({ id: 'file:src/bin/codegraph.ts', kind: 'file', name: 'codegraph.ts' }),
  166. file: 'src/bin/codegraph.ts',
  167. calls: 9,
  168. reaches: 37,
  169. dependents: 3,
  170. },
  171. ] as any,
  172. },
  173. hubs: {
  174. total: 1,
  175. shown: 1,
  176. truncated: false,
  177. items: [{ ...result({ id: 'method:get', name: 'get' }), dependents: 264 }] as any,
  178. },
  179. ...over,
  180. } as WireEntryPoints;
  181. }
  182. describe('the entry points', () => {
  183. it('says what each row is derived from, not that it IS the entry point', () => {
  184. const palette = buildEntryPalette(entryPoints());
  185. expect(palette.sections.map((s) => s.title)).toEqual([
  186. 'Files that run something',
  187. 'Most depended on',
  188. ]);
  189. expect(palette.sections[0]?.items[0]?.meta).toBe(
  190. '9 calls at module level · reaches 37 files'
  191. );
  192. expect(palette.sections[1]?.items[0]?.meta).toBe('264 dependents');
  193. expect(palette.items).toHaveLength(2);
  194. });
  195. it('puts routes first, and carries the id that makes a row clickable', () => {
  196. const palette = buildEntryPalette(
  197. entryPoints({
  198. routes: {
  199. routed: true,
  200. routeCount: 4,
  201. items: [
  202. {
  203. url: 'GET /users',
  204. handler: 'listUsers',
  205. file: 'src/routes.ts',
  206. line: 11,
  207. handlerId: 'function:listUsers',
  208. },
  209. ],
  210. },
  211. })
  212. );
  213. expect(palette.sections[0]?.title).toBe('Routes');
  214. const row = palette.items[0];
  215. expect(row?.type).toBe('route');
  216. if (row?.type === 'route') {
  217. expect(row.url).toBe('GET /users');
  218. expect(row.nodeId).toBe('function:listUsers');
  219. expect(row.location).toBe('routes.ts:11');
  220. }
  221. });
  222. it('shortens each section for the panel under the box', () => {
  223. const many = entryPoints();
  224. (many.hubs.items as any) = Array.from({ length: 10 }, (_, i) => ({
  225. ...result({ id: `m${i}`, name: `hub${i}` }),
  226. dependents: 100 - i,
  227. }));
  228. expect(buildEntryPalette(many, { perSection: 3 }).items).toHaveLength(4);
  229. expect(buildEntryPalette(many).items).toHaveLength(11);
  230. });
  231. it('draws nothing at all before the answer arrives', () => {
  232. const palette = buildEntryPalette(null);
  233. expect(palette.sections).toEqual([]);
  234. // Not an "empty" message: nothing is known yet, and saying "this index has
  235. // nothing" while the request is in flight would be a claim, not a state.
  236. expect(palette.empty).toBeNull();
  237. });
  238. });
  239. /* ----------------------------------------------------------------- trail -- */
  240. function hop(id: string, dir: TrailHop['dir']): TrailHop {
  241. return { id, name: null, kind: null, dir };
  242. }
  243. describe('the trail in the URL', () => {
  244. it('round-trips six hops with their directions intact', () => {
  245. const walked: TrailHop[] = [
  246. hop('method:a', 'start'),
  247. hop('method:b', 'down'),
  248. hop('method:c', 'down'),
  249. hop('method:d', 'up'),
  250. hop('method:e', 'down'),
  251. hop('file:src/bin/codegraph.ts', 'up'),
  252. ];
  253. const encoded = encodeTrail(walked);
  254. const decoded = decodeTrail(encoded);
  255. expect(decoded).toHaveLength(6);
  256. expect(decoded.map((h) => h.id)).toEqual(walked.map((h) => h.id));
  257. expect(decoded.map((h) => h.dir)).toEqual(['start', 'down', 'down', 'up', 'down', 'up']);
  258. // Re-encoding is byte-identical, which is what makes a shared link stable.
  259. expect(encodeTrail(decoded)).toBe(encoded);
  260. });
  261. it('keeps an id that begins with a direction letter', () => {
  262. // `union:…` and `default:…` start with 'u' and 'd'; an optional direction
  263. // prefix would swallow the first character of the id.
  264. const hops = [hop('union:Shape', 'start'), hop('declaration:x', 'down')];
  265. expect(decodeTrail(encodeTrail(hops)).map((h) => h.id)).toEqual([
  266. 'union:Shape',
  267. 'declaration:x',
  268. ]);
  269. });
  270. it('survives an id carrying the separator, and a hand-mangled param', () => {
  271. const hops = [hop('file:src/a,b.ts', 'start')];
  272. expect(decodeTrail(encodeTrail(hops))[0]?.id).toBe('file:src/a,b.ts');
  273. expect(decodeTrail(null)).toEqual([]);
  274. expect(decodeTrail('')).toEqual([]);
  275. // A token with no direction letter is dropped; a lone '%' would throw in
  276. // decodeURIComponent, so the raw text is kept instead — a hop that names
  277. // nothing is better than a trail that silently loses a position.
  278. expect(decodeTrail('x,,smethod%3Aa,d%')).toEqual([
  279. { id: 'method:a', name: null, kind: null, dir: 'start' },
  280. { id: '%', name: null, kind: null, dir: 'down' },
  281. ]);
  282. });
  283. it('labels an unresolved hop with something readable, never a raw hash', () => {
  284. expect(hopLabel({ ...hop('method:x', 'down'), name: 'load' })).toBe('load');
  285. expect(hopLabel(hop('file:src/bin/codegraph.ts', 'start'))).toBe('codegraph.ts');
  286. expect(hopLabel(hop('method:ada8ef1603fc03e3566eec72dc91138f', 'down'))).toBe('ada8ef16…');
  287. });
  288. });