ui-entry-model.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. /**
  2. * The entry-points panel's grouping, without a browser (CG-54).
  3. *
  4. * The half of `ui-entrypoints-api.test.ts` that needs no index: given a
  5. * payload, which rows exist, what they say, where they group, and which of them
  6. * can be clicked or turned into a flow. The rules worth pinning are the ones a
  7. * refactor would quietly break:
  8. *
  9. * - `panel.rows` is exactly the sections' rows in draw order (the same identity
  10. * the search palette rests its keyboard on).
  11. * - A route with no resolved handler still appears, but carries no target — a
  12. * row that looks clickable and is not is worse than a row that says so.
  13. * - Only a row that names a callable symbol offers a flow.
  14. */
  15. import { describe, it, expect } from 'vitest';
  16. import {
  17. buildEntryPanel,
  18. directoryOf,
  19. flowPair,
  20. frameworkPhrase,
  21. groupRows,
  22. matchEntries,
  23. originLabel,
  24. routeRow,
  25. type EntryRow,
  26. } from '../ui/src/lib/entry-model';
  27. import type {
  28. WireEntryFile,
  29. WireEntryHub,
  30. WireEntryPoints,
  31. WireEntryRoute,
  32. WireEntryTest,
  33. WireNodeRef,
  34. } from '../ui/src/lib/api';
  35. /* ------------------------------------------------------------- fixtures -- */
  36. function ref(over: Partial<WireNodeRef> = {}): WireNodeRef {
  37. return {
  38. id: 'function:x',
  39. name: 'x',
  40. kind: 'function',
  41. qualifiedName: 'x',
  42. file: 'src/x.ts',
  43. line: 1,
  44. endLine: 2,
  45. language: 'typescript',
  46. signature: null,
  47. exported: true,
  48. generated: false,
  49. test: false,
  50. ...over,
  51. } as WireNodeRef;
  52. }
  53. function route(over: Partial<WireEntryRoute> = {}): WireEntryRoute {
  54. return {
  55. url: 'POST /v1/payroll/cycles/{cycleID}/run',
  56. method: 'POST',
  57. path: '/v1/payroll/cycles/{cycleID}/run',
  58. handler: 'RunCycle',
  59. handlerKind: 'method',
  60. file: 'internal/transport/httpapi/payroll_handler.go',
  61. line: 34,
  62. handlerId: 'method:RunCycle',
  63. routeFile: 'internal/transport/httpapi/router.go',
  64. routeLine: 9,
  65. routeId: 'route:router.go:9:POST:/v1/payroll/cycles/{cycleID}/run',
  66. ...over,
  67. };
  68. }
  69. function file(over: Partial<WireEntryFile> = {}): WireEntryFile {
  70. return {
  71. ...ref({ id: 'file:src/bin/cli.ts', kind: 'file', name: 'cli.ts', file: 'src/bin/cli.ts' }),
  72. calls: 9,
  73. reaches: 37,
  74. dependents: 3,
  75. ...over,
  76. } as WireEntryFile;
  77. }
  78. function test(over: Partial<WireEntryTest> = {}): WireEntryTest {
  79. return {
  80. ...ref({
  81. id: 'file:__tests__/a.test.ts',
  82. kind: 'file',
  83. name: 'a.test.ts',
  84. file: '__tests__/a.test.ts',
  85. }),
  86. reaches: 12,
  87. refs: 40,
  88. ...over,
  89. } as WireEntryTest;
  90. }
  91. function hub(over: Partial<WireEntryHub> = {}): WireEntryHub {
  92. return {
  93. ...ref({ id: 'interface:Node', name: 'Node', kind: 'interface', file: 'src/types.ts', line: 42 }),
  94. dependents: 264,
  95. ...over,
  96. } as WireEntryHub;
  97. }
  98. function payload(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
  99. return {
  100. frameworks: ['go'],
  101. routes: {
  102. routed: true,
  103. routeCount: 4,
  104. items: { total: 2, shown: 2, truncated: false, items: [route(), route({
  105. url: 'GET /healthz',
  106. method: 'GET',
  107. path: '/healthz',
  108. handler: 'health',
  109. handlerKind: 'function',
  110. file: 'internal/transport/httpapi/router.go',
  111. line: 16,
  112. handlerId: 'function:health',
  113. routeLine: 12,
  114. routeId: 'route:router.go:12:GET:/healthz',
  115. })] },
  116. },
  117. files: { total: 92, shown: 1, truncated: true, items: [file()] },
  118. tests: { total: 1, shown: 1, truncated: false, items: [test()] },
  119. hubs: { total: 351, shown: 1, truncated: true, items: [hub()] },
  120. index: { lastIndexedAt: 1, files: 20 },
  121. timing: { elapsedMs: 3, cached: false },
  122. ...over,
  123. } as WireEntryPoints;
  124. }
  125. /* ---------------------------------------------------------------- panel -- */
  126. describe('the entry-points panel', () => {
  127. it('draws every section it has data for, in reading order', () => {
  128. const panel = buildEntryPanel(payload());
  129. expect(panel.sections.map((s) => s.id)).toEqual(['routes', 'files', 'tests', 'hubs']);
  130. expect(panel.sections.map((s) => s.title)).toEqual([
  131. 'Routes',
  132. 'Top-level files with calls',
  133. 'Tests',
  134. 'Most depended on',
  135. ]);
  136. });
  137. it('keeps `rows` exactly the sections it draws', () => {
  138. const panel = buildEntryPanel(payload());
  139. expect(panel.rows).toEqual(panel.sections.flatMap((s) => s.groups.flatMap((g) => g.rows)));
  140. expect(panel.rows).toHaveLength(5);
  141. });
  142. it('names the framework beside the route count', () => {
  143. const panel = buildEntryPanel(payload());
  144. expect(panel.sections[0]?.meta).toBe('2 · go');
  145. });
  146. it('groups routes by where they are REGISTERED, not where they are served', () => {
  147. const panel = buildEntryPanel(payload());
  148. const routes = panel.sections[0];
  149. // Two routes served from two different files, one router.
  150. expect(routes?.groups).toHaveLength(1);
  151. expect(routes?.groups[0]?.path).toBe('internal/transport/httpapi/router.go');
  152. expect(routes?.groups[0]?.file).toBe('internal/transport/httpapi/router.go');
  153. });
  154. it('says a list was cut, and whether the total is a floor', () => {
  155. const panel = buildEntryPanel(payload());
  156. expect(panel.sections.find((s) => s.id === 'files')?.meta).toBe('1 of at least 92');
  157. expect(panel.sections.find((s) => s.id === 'tests')?.meta).toBe('1');
  158. expect(panel.sections.find((s) => s.id === 'hubs')?.floor).toBe(true);
  159. expect(panel.sections.find((s) => s.id === 'tests')?.floor).toBe(false);
  160. });
  161. it('draws no Routes heading when the project is not a routed app', () => {
  162. const panel = buildEntryPanel(
  163. payload({
  164. routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
  165. })
  166. );
  167. // The fallback is the point: an empty box under a heading reads as a
  168. // failure, and a library legitimately has no routes.
  169. expect(panel.sections.map((s) => s.id)).toEqual(['files', 'tests', 'hubs']);
  170. expect(panel.empty).toBeNull();
  171. });
  172. it('says what is missing when there is nothing at all', () => {
  173. const panel = buildEntryPanel(
  174. payload({
  175. routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
  176. files: { total: 0, shown: 0, truncated: false, items: [] },
  177. tests: { total: 0, shown: 0, truncated: false, items: [] },
  178. hubs: { total: 0, shown: 0, truncated: false, items: [] },
  179. })
  180. );
  181. expect(panel.sections).toEqual([]);
  182. expect(panel.empty).toMatch(/no routes/);
  183. });
  184. it('draws nothing at all before the answer arrives', () => {
  185. const panel = buildEntryPanel(null);
  186. expect(panel.sections).toEqual([]);
  187. // Not an "empty" message: nothing is known yet, and saying "this index has
  188. // nothing" while the request is in flight would be a claim, not a state.
  189. expect(panel.empty).toBeNull();
  190. });
  191. });
  192. /* ------------------------------------------------------------------ rows -- */
  193. describe('an entry-point row', () => {
  194. it('leads a route with its verb and names the handler in the meta', () => {
  195. const row = routeRow(route());
  196. expect(row.method).toBe('POST');
  197. expect(row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
  198. expect(row.meta).toBe('RunCycle · payroll_handler.go:34');
  199. expect(row.title).toContain('registered at internal/transport/httpapi/router.go:9');
  200. });
  201. it('keeps an unplaceable route but does not pretend it opens', () => {
  202. const row = routeRow(route({ handlerId: null }));
  203. expect(row.target).toBeNull();
  204. expect(row.flowFrom).toBeNull();
  205. expect(row.meta).toBe('RunCycle · not in the index');
  206. });
  207. it('offers a flow only from a row that names a callable symbol', () => {
  208. const panel = buildEntryPanel(payload());
  209. const byId = (id: string) => panel.sections.find((s) => s.id === id);
  210. expect(byId('routes')?.groups[0]?.rows[0]?.flowFrom).toBe('RunCycle');
  211. expect(byId('hubs')?.groups[0]?.rows[0]?.flowFrom).toBe('Node');
  212. // A file has no name `/api/flow` can look up; a chip here would always fail.
  213. expect(byId('files')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
  214. expect(byId('tests')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
  215. });
  216. it('sends a file row to the File view and a symbol row to the symbol', () => {
  217. const panel = buildEntryPanel(payload());
  218. expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.target).toEqual({
  219. type: 'file',
  220. path: 'src/bin/cli.ts',
  221. });
  222. expect(panel.sections.find((s) => s.id === 'hubs')?.groups[0]?.rows[0]?.target).toEqual({
  223. type: 'symbol',
  224. id: 'interface:Node',
  225. name: 'Node',
  226. kind: 'interface',
  227. });
  228. });
  229. it('says when nothing imports an executable file', () => {
  230. const panel = buildEntryPanel(
  231. payload({ files: { total: 1, shown: 1, truncated: false, items: [file({ dependents: 0 })] } })
  232. );
  233. expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.meta).toBe(
  234. '9 calls at module level · reaches 37 files · nothing imports it'
  235. );
  236. });
  237. });
  238. /* -------------------------------------------------------------- grouping -- */
  239. describe('grouping', () => {
  240. it('folds by path in first-seen order, so the ranking stays visible', () => {
  241. const row = (id: string): EntryRow => ({
  242. id,
  243. name: id,
  244. method: null,
  245. meta: '',
  246. kind: 'file',
  247. target: null,
  248. flowFrom: null,
  249. title: id,
  250. });
  251. const groups = groupRows([
  252. { row: row('b1'), path: 'b', file: null },
  253. { row: row('a1'), path: 'a', file: null },
  254. { row: row('b2'), path: 'b', file: null },
  255. ]);
  256. expect(groups.map((g) => g.path)).toEqual(['b', 'a']);
  257. expect(groups[0]?.rows.map((r) => r.id)).toEqual(['b1', 'b2']);
  258. });
  259. it('names the directory, or the project root', () => {
  260. expect(directoryOf('src/bin/cli.ts')).toBe('src/bin');
  261. expect(directoryOf('package.json')).toBe('project root');
  262. });
  263. });
  264. /* --------------------------------------------------------------- palette -- */
  265. describe('entry points under a typed query', () => {
  266. it('matches on anything the row draws, including the handler', () => {
  267. const matches = matchEntries(payload(), 'runcycle', 6);
  268. expect(matches).toHaveLength(1);
  269. expect(matches[0]?.origin).toBe('route');
  270. expect(matches[0]?.row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
  271. });
  272. it('matches a URL a search for the path would find, and a verb one would not', () => {
  273. expect(matchEntries(payload(), 'healthz', 6)).toHaveLength(1);
  274. expect(matchEntries(payload(), 'post ', 6)).toHaveLength(1);
  275. });
  276. it('honours the cap and answers nothing for an empty query', () => {
  277. expect(matchEntries(payload(), '', 6)).toEqual([]);
  278. expect(matchEntries(null, 'x', 6)).toEqual([]);
  279. expect(matchEntries(payload(), '.', 1)).toHaveLength(1);
  280. });
  281. it('says where each match came from', () => {
  282. expect(originLabel('route')).toBe('route');
  283. expect(originLabel('file')).toBe('runs at module level');
  284. expect(originLabel('test')).toBe('test');
  285. expect(originLabel('hub')).toBe('depended on');
  286. });
  287. });
  288. /* ------------------------------------------------------------------ flow -- */
  289. describe('starting a flow from a row', () => {
  290. it('refuses a pair that is not a question', () => {
  291. expect(flowPair('RunCycle', '')).toBeNull();
  292. expect(flowPair('', 'Upsert')).toBeNull();
  293. // `/api/flow` refuses this with a 400; disabling the button is kinder.
  294. expect(flowPair('Upsert', 'upsert')).toBeNull();
  295. });
  296. it('trims what was typed', () => {
  297. expect(flowPair(' RunCycle ', ' Upsert ')).toEqual({ from: 'RunCycle', to: 'Upsert' });
  298. });
  299. });
  300. describe('naming the frameworks', () => {
  301. it('reads as a sentence, however many there are', () => {
  302. expect(frameworkPhrase([])).toBe('');
  303. expect(frameworkPhrase(['gin'])).toBe('gin');
  304. expect(frameworkPhrase(['gin', 'spring'])).toBe('gin and spring');
  305. expect(frameworkPhrase(['gin', 'spring', 'rails'])).toBe('gin, spring and rails');
  306. });
  307. });