| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- /**
- * The entry-points panel's grouping, without a browser (CG-54).
- *
- * The half of `ui-entrypoints-api.test.ts` that needs no index: given a
- * payload, which rows exist, what they say, where they group, and which of them
- * can be clicked or turned into a flow. The rules worth pinning are the ones a
- * refactor would quietly break:
- *
- * - `panel.rows` is exactly the sections' rows in draw order (the same identity
- * the search palette rests its keyboard on).
- * - A route with no resolved handler still appears, but carries no target — a
- * row that looks clickable and is not is worse than a row that says so.
- * - Only a row that names a callable symbol offers a flow.
- */
- import { describe, it, expect } from 'vitest';
- import {
- buildEntryPanel,
- directoryOf,
- flowPair,
- frameworkPhrase,
- groupRows,
- matchEntries,
- originLabel,
- routeRow,
- type EntryRow,
- } from '../ui/src/lib/entry-model';
- import type {
- WireEntryFile,
- WireEntryHub,
- WireEntryPoints,
- WireEntryRoute,
- WireEntryTest,
- WireNodeRef,
- } from '../ui/src/lib/api';
- /* ------------------------------------------------------------- fixtures -- */
- function ref(over: Partial<WireNodeRef> = {}): WireNodeRef {
- return {
- id: 'function:x',
- name: 'x',
- kind: 'function',
- qualifiedName: 'x',
- file: 'src/x.ts',
- line: 1,
- endLine: 2,
- language: 'typescript',
- signature: null,
- exported: true,
- generated: false,
- test: false,
- ...over,
- } as WireNodeRef;
- }
- function route(over: Partial<WireEntryRoute> = {}): WireEntryRoute {
- return {
- url: 'POST /v1/payroll/cycles/{cycleID}/run',
- method: 'POST',
- path: '/v1/payroll/cycles/{cycleID}/run',
- handler: 'RunCycle',
- handlerKind: 'method',
- file: 'internal/transport/httpapi/payroll_handler.go',
- line: 34,
- handlerId: 'method:RunCycle',
- routeFile: 'internal/transport/httpapi/router.go',
- routeLine: 9,
- routeId: 'route:router.go:9:POST:/v1/payroll/cycles/{cycleID}/run',
- ...over,
- };
- }
- function file(over: Partial<WireEntryFile> = {}): WireEntryFile {
- return {
- ...ref({ id: 'file:src/bin/cli.ts', kind: 'file', name: 'cli.ts', file: 'src/bin/cli.ts' }),
- calls: 9,
- reaches: 37,
- dependents: 3,
- ...over,
- } as WireEntryFile;
- }
- function test(over: Partial<WireEntryTest> = {}): WireEntryTest {
- return {
- ...ref({
- id: 'file:__tests__/a.test.ts',
- kind: 'file',
- name: 'a.test.ts',
- file: '__tests__/a.test.ts',
- }),
- reaches: 12,
- refs: 40,
- ...over,
- } as WireEntryTest;
- }
- function hub(over: Partial<WireEntryHub> = {}): WireEntryHub {
- return {
- ...ref({ id: 'interface:Node', name: 'Node', kind: 'interface', file: 'src/types.ts', line: 42 }),
- dependents: 264,
- ...over,
- } as WireEntryHub;
- }
- function payload(over: Partial<WireEntryPoints> = {}): WireEntryPoints {
- return {
- frameworks: ['go'],
- routes: {
- routed: true,
- routeCount: 4,
- items: { total: 2, shown: 2, truncated: false, items: [route(), route({
- url: 'GET /healthz',
- method: 'GET',
- path: '/healthz',
- handler: 'health',
- handlerKind: 'function',
- file: 'internal/transport/httpapi/router.go',
- line: 16,
- handlerId: 'function:health',
- routeLine: 12,
- routeId: 'route:router.go:12:GET:/healthz',
- })] },
- },
- files: { total: 92, shown: 1, truncated: true, items: [file()] },
- tests: { total: 1, shown: 1, truncated: false, items: [test()] },
- hubs: { total: 351, shown: 1, truncated: true, items: [hub()] },
- index: { lastIndexedAt: 1, files: 20 },
- timing: { elapsedMs: 3, cached: false },
- ...over,
- } as WireEntryPoints;
- }
- /* ---------------------------------------------------------------- panel -- */
- describe('the entry-points panel', () => {
- it('draws every section it has data for, in reading order', () => {
- const panel = buildEntryPanel(payload());
- expect(panel.sections.map((s) => s.id)).toEqual(['routes', 'files', 'tests', 'hubs']);
- expect(panel.sections.map((s) => s.title)).toEqual([
- 'Routes',
- 'Top-level files with calls',
- 'Tests',
- 'Most depended on',
- ]);
- });
- it('keeps `rows` exactly the sections it draws', () => {
- const panel = buildEntryPanel(payload());
- expect(panel.rows).toEqual(panel.sections.flatMap((s) => s.groups.flatMap((g) => g.rows)));
- expect(panel.rows).toHaveLength(5);
- });
- it('names the framework beside the route count', () => {
- const panel = buildEntryPanel(payload());
- expect(panel.sections[0]?.meta).toBe('2 · go');
- });
- it('groups routes by where they are REGISTERED, not where they are served', () => {
- const panel = buildEntryPanel(payload());
- const routes = panel.sections[0];
- // Two routes served from two different files, one router.
- expect(routes?.groups).toHaveLength(1);
- expect(routes?.groups[0]?.path).toBe('internal/transport/httpapi/router.go');
- expect(routes?.groups[0]?.file).toBe('internal/transport/httpapi/router.go');
- });
- it('says a list was cut, and whether the total is a floor', () => {
- const panel = buildEntryPanel(payload());
- expect(panel.sections.find((s) => s.id === 'files')?.meta).toBe('1 of at least 92');
- expect(panel.sections.find((s) => s.id === 'tests')?.meta).toBe('1');
- expect(panel.sections.find((s) => s.id === 'hubs')?.floor).toBe(true);
- expect(panel.sections.find((s) => s.id === 'tests')?.floor).toBe(false);
- });
- it('draws no Routes heading when the project is not a routed app', () => {
- const panel = buildEntryPanel(
- payload({
- routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
- })
- );
- // The fallback is the point: an empty box under a heading reads as a
- // failure, and a library legitimately has no routes.
- expect(panel.sections.map((s) => s.id)).toEqual(['files', 'tests', 'hubs']);
- expect(panel.empty).toBeNull();
- });
- it('says what is missing when there is nothing at all', () => {
- const panel = buildEntryPanel(
- payload({
- routes: { routed: false, routeCount: 0, items: { total: 0, shown: 0, truncated: false, items: [] } },
- files: { total: 0, shown: 0, truncated: false, items: [] },
- tests: { total: 0, shown: 0, truncated: false, items: [] },
- hubs: { total: 0, shown: 0, truncated: false, items: [] },
- })
- );
- expect(panel.sections).toEqual([]);
- expect(panel.empty).toMatch(/no routes/);
- });
- it('draws nothing at all before the answer arrives', () => {
- const panel = buildEntryPanel(null);
- expect(panel.sections).toEqual([]);
- // Not an "empty" message: nothing is known yet, and saying "this index has
- // nothing" while the request is in flight would be a claim, not a state.
- expect(panel.empty).toBeNull();
- });
- });
- /* ------------------------------------------------------------------ rows -- */
- describe('an entry-point row', () => {
- it('leads a route with its verb and names the handler in the meta', () => {
- const row = routeRow(route());
- expect(row.method).toBe('POST');
- expect(row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
- expect(row.meta).toBe('RunCycle · payroll_handler.go:34');
- expect(row.title).toContain('registered at internal/transport/httpapi/router.go:9');
- });
- it('keeps an unplaceable route but does not pretend it opens', () => {
- const row = routeRow(route({ handlerId: null }));
- expect(row.target).toBeNull();
- expect(row.flowFrom).toBeNull();
- expect(row.meta).toBe('RunCycle · not in the index');
- });
- it('offers a flow only from a row that names a callable symbol', () => {
- const panel = buildEntryPanel(payload());
- const byId = (id: string) => panel.sections.find((s) => s.id === id);
- expect(byId('routes')?.groups[0]?.rows[0]?.flowFrom).toBe('RunCycle');
- expect(byId('hubs')?.groups[0]?.rows[0]?.flowFrom).toBe('Node');
- // A file has no name `/api/flow` can look up; a chip here would always fail.
- expect(byId('files')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
- expect(byId('tests')?.groups[0]?.rows[0]?.flowFrom).toBeNull();
- });
- it('sends a file row to the File view and a symbol row to the symbol', () => {
- const panel = buildEntryPanel(payload());
- expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.target).toEqual({
- type: 'file',
- path: 'src/bin/cli.ts',
- });
- expect(panel.sections.find((s) => s.id === 'hubs')?.groups[0]?.rows[0]?.target).toEqual({
- type: 'symbol',
- id: 'interface:Node',
- name: 'Node',
- kind: 'interface',
- });
- });
- it('says when nothing imports an executable file', () => {
- const panel = buildEntryPanel(
- payload({ files: { total: 1, shown: 1, truncated: false, items: [file({ dependents: 0 })] } })
- );
- expect(panel.sections.find((s) => s.id === 'files')?.groups[0]?.rows[0]?.meta).toBe(
- '9 calls at module level · reaches 37 files · nothing imports it'
- );
- });
- });
- /* -------------------------------------------------------------- grouping -- */
- describe('grouping', () => {
- it('folds by path in first-seen order, so the ranking stays visible', () => {
- const row = (id: string): EntryRow => ({
- id,
- name: id,
- method: null,
- meta: '',
- kind: 'file',
- target: null,
- flowFrom: null,
- title: id,
- });
- const groups = groupRows([
- { row: row('b1'), path: 'b', file: null },
- { row: row('a1'), path: 'a', file: null },
- { row: row('b2'), path: 'b', file: null },
- ]);
- expect(groups.map((g) => g.path)).toEqual(['b', 'a']);
- expect(groups[0]?.rows.map((r) => r.id)).toEqual(['b1', 'b2']);
- });
- it('names the directory, or the project root', () => {
- expect(directoryOf('src/bin/cli.ts')).toBe('src/bin');
- expect(directoryOf('package.json')).toBe('project root');
- });
- });
- /* --------------------------------------------------------------- palette -- */
- describe('entry points under a typed query', () => {
- it('matches on anything the row draws, including the handler', () => {
- const matches = matchEntries(payload(), 'runcycle', 6);
- expect(matches).toHaveLength(1);
- expect(matches[0]?.origin).toBe('route');
- expect(matches[0]?.row.name).toBe('/v1/payroll/cycles/{cycleID}/run');
- });
- it('matches a URL a search for the path would find, and a verb one would not', () => {
- expect(matchEntries(payload(), 'healthz', 6)).toHaveLength(1);
- expect(matchEntries(payload(), 'post ', 6)).toHaveLength(1);
- });
- it('honours the cap and answers nothing for an empty query', () => {
- expect(matchEntries(payload(), '', 6)).toEqual([]);
- expect(matchEntries(null, 'x', 6)).toEqual([]);
- expect(matchEntries(payload(), '.', 1)).toHaveLength(1);
- });
- it('says where each match came from', () => {
- expect(originLabel('route')).toBe('route');
- expect(originLabel('file')).toBe('runs at module level');
- expect(originLabel('test')).toBe('test');
- expect(originLabel('hub')).toBe('depended on');
- });
- });
- /* ------------------------------------------------------------------ flow -- */
- describe('starting a flow from a row', () => {
- it('refuses a pair that is not a question', () => {
- expect(flowPair('RunCycle', '')).toBeNull();
- expect(flowPair('', 'Upsert')).toBeNull();
- // `/api/flow` refuses this with a 400; disabling the button is kinder.
- expect(flowPair('Upsert', 'upsert')).toBeNull();
- });
- it('trims what was typed', () => {
- expect(flowPair(' RunCycle ', ' Upsert ')).toEqual({ from: 'RunCycle', to: 'Upsert' });
- });
- });
- describe('naming the frameworks', () => {
- it('reads as a sentence, however many there are', () => {
- expect(frameworkPhrase([])).toBe('');
- expect(frameworkPhrase(['gin'])).toBe('gin');
- expect(frameworkPhrase(['gin', 'spring'])).toBe('gin and spring');
- expect(frameworkPhrase(['gin', 'spring', 'rails'])).toBe('gin, spring and rails');
- });
- });
|