feat(ui): the Flow strip — how one symbol reaches another, one card per hop (CG-50)

Ask "how does execute reach getFile" in the search box and the viewer draws the
call path between them, left to right, opening every card at the exact line that
makes the next call. Dynamic-dispatch hops are dashed and name the site they
were wired at; "Read as flow" turns a trail walked by hand into the same strip.

The path finder is NOT new. `codegraph_explore` already leads its answers with
the longest call chain among the symbols an agent named, and a viewer that drew
a different path would get the two quoted against each other in a review. So the
search moved out of `ToolHandler` into `src/graph/named-symbol-flow.ts` and both
callers ride it — same tokens, same overload rules, same synthesized edges. What
stayed behind in `tools.ts` is the prose.

A pinned from/to question is the same search with two options changed, because
both ends being named is the evidence explore's one-unnamed-bridge cap stands in
for: it bridges freely, keeps twelve candidates per endpoint instead of six
(the CLI's own `main` sorts seventh of ten), and searches from both ends at once
— identical paths to the one-way walk on twelve measured pairs, 3-6x faster.

`/api/flow` is deliberately the one endpoint with no cache: its cards carry
source read from disk, and a drift verdict changes without the index changing.

Verified on this repo (`execute` to `rowToFileRecord`, 8 hops; `main` to
`resolveOne`, 7) and on a fresh excalidraw index, where `mutateElement` to
`renderStaticScene` crosses callback, react-render and jsx-child hops and lists
exactly the hops `codegraph_explore` prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenryandClaude Opus 5 committed 2026-08-27 03:19:24 -05:00
1 parent 6d0f60f32c
commit 62e0a89b0e
24 files changed
+3326 -313

No files matched your search

+452
View File
@@ -0,0 +1,452 @@
/**
* `GET /api/flow` — the call path behind the Flow strip (CG-50).
*
* Against a real indexed fixture over a real loopback server, like the rest of
* the viewer's API suite. The fixture is shaped to produce the four things this
* endpoint has to get right and that a synthetic payload cannot prove:
*
* - a real five-hop chain of calls, so the hops, their edges, and the line each
* card is opened at all come out of the graph rather than out of a fixture
* object,
* - two definitions of the same name, one of them in a test file, so the
* directed search's overload handling and the `ambiguous` report can be
* checked (this is the shape that broke `main` on the engine's own index —
* the right definition sorted seventh),
* - a symbol nothing reaches, so "no path" is exercised as the ordinary answer
* it is rather than as an error,
* - a Go interface with one implementation, so a SYNTHESIZED hop — the thing
* the strip draws dashed and labels with its wiring site — is a real edge
* from the resolver rather than a hand-written metadata blob.
*
* The pure geometry is tested without a server in `ui-flow-model.test.ts`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as http from 'http';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
import { flowEdgeLabel, parseFlowQuery } from '../src/ui-server/api/flow';
import { resolveNamedSymbolFlow } from '../src/graph/named-symbol-flow';
import type { Edge } from '../src/types';
let server: UiServerHandle;
let api: GraphApi;
let tempDir: string;
let projectRoot: string;
function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
host: '127.0.0.1',
port: server.port,
path: requestPath,
method: 'GET',
headers: { Host: `127.0.0.1:${server.port}` },
setHost: false,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf-8'),
type: res.headers['content-type'],
})
);
}
);
req.on('error', reject);
req.end();
});
}
async function getFlow(query: string, expected = 200): Promise<any> {
const res = await request(`/api/flow${query}`);
expect(res.type).toBe('application/json; charset=utf-8');
expect(res.status).toBe(expected);
return JSON.parse(res.body);
}
function write(root: string, rel: string, body: string): void {
const full = path.join(root, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, body);
}
/** `name` at each hop, so an assertion reads like the strip does. */
function names(flow: any): string[] {
return flow.hops.map((h: any) => h.node.name);
}
beforeAll(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-flow-'));
projectRoot = path.join(tempDir, 'project');
// A five-hop chain: bootstrap -> handleRequest -> loadRow -> readRow -> toRow.
write(
projectRoot,
'src/main.ts',
`import { handleRequest } from './server/handler';
export function bootstrap(): string {
const banner = 'ready';
return handleRequest(banner);
}
`
);
write(
projectRoot,
'src/server/handler.ts',
`import { loadRow } from '../db/rows';
export function handleRequest(id: string): string {
const trimmed = id.trim();
return loadRow(trimmed);
}
/** Nothing on the chain calls this — it is the "no path" endpoint. */
export function orphanHandler(): string {
return 'nobody calls me';
}
`
);
write(
projectRoot,
'src/db/rows.ts',
`export function loadRow(id: string): string {
return readRow(id);
}
function readRow(id: string): string {
return toRow(id);
}
function toRow(id: string): string {
return id.toUpperCase();
}
`
);
// Two `describe` definitions, one of them in a test file: the ambiguity the
// directed search has to walk past rather than truncate away.
write(
projectRoot,
'src/db/describe.ts',
`import { loadRow } from './rows';
export function describeRow(id: string): string {
return loadRow(id);
}
`
);
write(
projectRoot,
'__tests__/rows.test.ts',
`export function describeRow(id: string): string {
return id;
}
`
);
// A Go interface with one implementation: the resolver synthesizes an
// interface-impl `calls` edge across it, which is what the strip draws dashed.
write(
projectRoot,
'go/clock.go',
`package clock
type Clock interface {
Now() string
}
type SystemClock struct{}
func (SystemClock) Now() string {
return stamp()
}
func stamp() string {
return "now"
}
func Tick(c Clock) string {
return c.Now()
}
`
);
const cg = CodeGraph.initSync(projectRoot, {
config: { include: ['src/**/*.ts', '__tests__/**/*.ts', 'go/**/*.go'], exclude: [] },
});
await cg.indexAll();
cg.resolveReferences();
cg.close();
const viewerDir = path.join(tempDir, 'viewer');
fs.mkdirSync(viewerDir, { recursive: true });
fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
api = createGraphApi({ projectRoot });
server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
}, 120_000);
afterAll(async () => {
api?.close();
await server?.close();
if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('parseFlowQuery', () => {
it('reads the three shapes and refuses the empty one', () => {
expect(parseFlowQuery(new URLSearchParams('from=a&to=b'))).toEqual({
kind: 'directed',
from: 'a',
to: 'b',
});
expect(parseFlowQuery(new URLSearchParams('symbols=a,b,c'))).toEqual({
kind: 'symbols',
text: 'a,b,c',
});
expect(parseFlowQuery(new URLSearchParams('hop=sx&hop=dy&hop=uz'))).toEqual({
kind: 'trail',
hops: [
{ id: 'x', dir: 'start' },
{ id: 'y', dir: 'down' },
{ id: 'z', dir: 'up' },
],
});
expect(() => parseFlowQuery(new URLSearchParams(''))).toThrow(/No flow was asked for/);
});
it('refuses a pair that names the same symbol twice', () => {
expect(() => parseFlowQuery(new URLSearchParams('from=run&to=run'))).toThrow(/same symbol/);
});
it('takes a trail over a from/to pair, and refuses a one-hop trail', () => {
// A hop parameter is only ever sent by "Read as flow", which is a complete
// question on its own; a stray `from` alongside it must not be searched.
const parsed = parseFlowQuery(new URLSearchParams('from=a&to=b&hop=sx&hop=dy'));
expect(parsed.kind).toBe('trail');
expect(() => parseFlowQuery(new URLSearchParams('hop=sx'))).toThrow(/at least two hops/);
});
});
describe('flowEdgeLabel', () => {
const edge = (metadata: Record<string, unknown>, provenance = 'heuristic'): Edge =>
({ kind: 'calls', source: 'a', target: 'b', provenance, metadata }) as unknown as Edge;
it('names the mechanism and the wiring site for a synthesized hop', () => {
expect(
flowEdgeLabel(edge({ synthesizedBy: 'callback', registeredAt: 'src/a.ts:12' }), false)
).toBe('via callback · registered at src/a.ts:12');
});
it('never lets a synthesized hop read as a plain call', () => {
expect(flowEdgeLabel(edge({ synthesizedBy: 'react-render' }), false)).toBe('via react render');
});
it('says "called by" when the reader walked the edge backwards', () => {
expect(flowEdgeLabel(edge({}, 'resolved'), true)).toBe('called by');
expect(flowEdgeLabel(edge({}, 'resolved'), false)).toBe('calls');
});
});
describe('GET /api/flow — a directed question', () => {
it('returns the whole chain, one hop per card', async () => {
const payload = await getFlow('?from=bootstrap&to=toRow');
expect(payload.query).toMatchObject({ kind: 'directed', from: 'bootstrap', to: 'toRow' });
expect(payload.reason).toBeNull();
expect(payload.flows).toHaveLength(1);
expect(names(payload.flows[0])).toEqual([
'bootstrap',
'handleRequest',
'loadRow',
'readRow',
'toRow',
]);
expect(payload.flows[0].label).toBe('bootstrap → toRow');
});
it('opens each card at the line that calls the next one', async () => {
const { flows } = await getFlow('?from=bootstrap&to=toRow');
const hops = flows[0].hops;
for (let i = 0; i < hops.length - 1; i++) {
const ref = hops[i].callRef;
expect(ref, `hop ${i} has a call site`).not.toBeNull();
expect(ref.name).toBe(hops[i + 1].node.name);
expect(ref.targetId).toBe(hops[i + 1].node.id);
expect(ref.backwards).toBe(false);
// The window is centred on it, and the source really contains it.
expect(ref.line).toBeGreaterThanOrEqual(hops[i].source.from);
expect(ref.line).toBeLessThanOrEqual(hops[i].source.to);
const offset = ref.line - hops[i].source.from;
expect(hops[i].source.lines[offset]).toContain(hops[i + 1].node.name);
}
// The last card has nothing to call, so it opens at its own definition.
const last = hops[hops.length - 1];
expect(last.callRef).toBeNull();
expect(last.source.from).toBeLessThanOrEqual(last.node.line);
expect(last.source.to).toBeGreaterThanOrEqual(last.node.line);
});
it('carries the edge on every hop but the first, with its line', async () => {
const { flows } = await getFlow('?from=bootstrap&to=toRow');
const hops = flows[0].hops;
expect(hops[0].edge).toBeNull();
for (let i = 1; i < hops.length; i++) {
expect(hops[i].edge.kind).toBe('calls');
expect(hops[i].edge.label).toBe('calls');
expect(hops[i].edge.upward).toBe(false);
expect(hops[i].edge.synthesized).toBe(false);
// The edge's line is the previous card's call site — the two agree, and
// the strip prints both, so a disagreement would be visible.
expect(hops[i].edge.line).toBe(hops[i - 1].callRef.line);
}
});
it('highlights each card with real source, never a drifted slice', async () => {
const { flows } = await getFlow('?from=bootstrap&to=toRow');
for (const hop of flows[0].hops) {
expect(hop.source.drift).toBe(false);
expect(hop.source.lines.length).toBeGreaterThan(0);
expect(hop.source.lines.length).toBe(hop.source.to - hop.source.from + 1);
// Highlight rides with the slice and is line-for-line with it (CG-43).
expect(hop.source.highlight.lines).toHaveLength(hop.source.lines.length);
}
});
it('answers "not connected" as an ordinary answer, with a reason', async () => {
const payload = await getFlow('?from=bootstrap&to=orphanHandler');
expect(payload.flows).toEqual([]);
expect(payload.reason).toMatch(/No chain of calls reaches orphanHandler/);
expect(payload.reason).toMatch(/dynamic dispatch/);
expect(payload.unresolved).toEqual([]);
});
it('says which names matched nothing rather than blaming the path', async () => {
const payload = await getFlow('?from=bootstrap&to=thisNameIsNotHere');
expect(payload.unresolved).toEqual(['thisNameIsNotHere']);
expect(payload.reason).toMatch(/thisNameIsNotHere names nothing/);
});
it('walks past an overload in a test file and reports the ambiguity', async () => {
const payload = await getFlow('?from=describeRow&to=toRow');
expect(names(payload.flows[0])).toEqual(['describeRow', 'loadRow', 'readRow', 'toRow']);
const ambiguity = payload.ambiguous.find((a: any) => a.token === 'describeRow');
expect(ambiguity).toBeDefined();
expect(ambiguity.chosen.file).toBe('src/db/describe.ts');
expect(ambiguity.others.map((o: any) => o.file)).toContain('__tests__/rows.test.ts');
});
});
describe('GET /api/flow — a synthesized hop', () => {
it('draws the interface bridge as a dashed hop that names its mechanism', async () => {
const payload = await getFlow('?from=Tick&to=stamp');
expect(payload.flows.length).toBeGreaterThan(0);
const hops = payload.flows[0].hops;
expect(names(payload.flows[0])[0]).toBe('Tick');
expect(names(payload.flows[0]).at(-1)).toBe('stamp');
const synthesized = hops.filter((h: any) => h.edge?.synthesized);
expect(synthesized.length).toBeGreaterThan(0);
for (const hop of synthesized) {
expect(hop.edge.provenance).toBe('heuristic');
expect(hop.edge.label).toMatch(/^via /);
expect(hop.edge.label).not.toBe('calls');
}
});
});
describe('GET /api/flow — explore parity', () => {
it('answers a ?symbols= question with the chain the explore search finds', async () => {
const payload = await getFlow('?symbols=bootstrap,loadRow,toRow');
expect(payload.query.kind).toBe('symbols');
expect(payload.flows.length).toBeGreaterThan(0);
// The endpoint must not have its own path finder. Run the engine's directly
// and require the same hops, in the same order.
const cg = CodeGraph.openSync(projectRoot);
try {
const flow = resolveNamedSymbolFlow(cg, 'bootstrap,loadRow,toRow');
expect(flow.chains[0]?.steps.map((s) => s.node.id)).toEqual(
payload.flows[0].hops.map((h: any) => h.node.id)
);
} finally {
cg.close();
}
});
});
describe('GET /api/flow — a trail read as a flow', () => {
it('draws the hops it was given, finding the edge that already joins them', async () => {
const forward = await getFlow('?from=bootstrap&to=toRow');
const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id);
const query = ids
.map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'd'}${id}`)}`)
.join('&');
const payload = await getFlow(`?${query}`);
expect(payload.query.kind).toBe('trail');
expect(payload.flows[0].hops.map((h: any) => h.node.id)).toEqual(ids);
expect(payload.flows[0].hops[1].edge.kind).toBe('calls');
expect(payload.flows[0].hops[1].edge.upward).toBe(false);
});
it('reads a trail walked BACKWARDS as caller hops, opened at the calling line', async () => {
const forward = await getFlow('?from=bootstrap&to=toRow');
const ids: string[] = forward.flows[0].hops.map((h: any) => h.node.id).reverse();
const query = ids
.map((id, i) => `hop=${encodeURIComponent(`${i === 0 ? 's' : 'u'}${id}`)}`)
.join('&');
const payload = await getFlow(`?${query}`);
const hops = payload.flows[0].hops;
expect(hops.map((h: any) => h.node.id)).toEqual(ids);
// Every hop after the first is the caller of the one before it, so its own
// body holds the call — and the card opens there, pointing BACK.
for (let i = 1; i < hops.length; i++) {
expect(hops[i].edge.upward).toBe(true);
expect(hops[i].edge.label).toBe('called by');
expect(hops[i].callRef.backwards).toBe(true);
expect(hops[i].callRef.name).toBe(hops[i - 1].node.name);
expect(hops[i].callRef.line).toBe(hops[i].edge.line);
}
// The first card is the callee: nothing in it calls anything on this trail.
expect(hops[0].callRef).toBeNull();
});
it('says so when the ids on a trail are no longer in the index', async () => {
const payload = await getFlow('?hop=smethod%3Agone&hop=dmethod%3Aalso-gone');
expect(payload.flows).toEqual([]);
expect(payload.unresolved).toEqual(['method:gone', 'method:also-gone']);
expect(payload.reason).toMatch(/still in the index/);
});
});
describe('GET /api/flow — refusals', () => {
it('answers JSON, not text, when the question is malformed', async () => {
const payload = await getFlow('', 400);
expect(payload.code).toBe('bad-request');
expect(payload.error).toMatch(/No flow was asked for/);
expect(payload.hint).toMatch(/\?from=/);
});
it('caps the number of trail hops it will read', async () => {
const query = Array.from({ length: 40 }, (_, i) => `hop=s${i}xx`).join('&');
const payload = await getFlow(`?${query}`, 400);
expect(payload.code).toBe('bad-request');
expect(payload.error).toMatch(/longer than this endpoint reads/);
});
it('is listed on the API index', async () => {
const res = await request('/api');
const body = JSON.parse(res.body);
const entry = body.endpoints.find((e: any) => e.path === '/api/flow');
expect(entry).toBeDefined();
expect(entry.params).toContain('from');
expect(entry.params).toContain('hop');
});
});
+293
View File
@@ -0,0 +1,293 @@
/**
* The Flow strip's geometry (CG-50) — `ui/src/lib/flow-model.ts`.
*
* Pure functions, no browser: this is where the strip's two load-bearing claims
* are checked. That a card's height is ARITHMETIC (the CSS pins the same
* number, so an arrow lands where the layout said it would), and that a column
* is a card's LONGEST distance from a start (so two routes that rejoin do so in
* the same column, and nothing is ever drawn left of something that calls it).
*
* The endpoint that feeds it is tested against a real index in
* `ui-flow-api.test.ts`.
*/
import { describe, it, expect } from 'vitest';
import {
buildFlowLayout,
cardHeight,
dashFor,
labelLinesFor,
lineLabelFor,
CARD_WIDTH,
CODE_LINE_HEIGHT,
CODE_PADDING,
COLUMN_PITCH,
HEADER_HEIGHT,
LABEL_MAX_CHARS,
LINK_WIDTH,
NO_SOURCE_HEIGHT,
PADDING,
ROW_GAP,
} from '../ui/src/lib/flow-model';
import type { WireFlow, WireFlowEdge, WireFlowHop } from '../ui/src/lib/api';
/* ------------------------------------------------------------- builders -- */
function edge(over: Partial<WireFlowEdge> = {}): WireFlowEdge {
return {
kind: 'calls',
label: 'calls',
upward: false,
uncertain: false,
synthesized: false,
...over,
};
}
function hop(name: string, opts: { lines?: number; edge?: WireFlowEdge | null } = {}): WireFlowHop {
const lines = opts.lines ?? 7;
return {
node: {
id: `method:${name}`,
kind: 'method',
name,
qualifiedName: name,
file: `src/${name}.ts`,
line: 10,
endLine: 40,
language: 'typescript',
test: false,
},
edge: opts.edge === undefined ? edge() : opts.edge,
callRef: null,
source:
lines === 0
? null
: {
file: `src/${name}.ts`,
language: 'typescript',
from: 7,
to: 6 + lines,
lines: Array.from({ length: lines }, (_, i) => `line ${i}`),
drift: false,
},
};
}
function flow(id: string, names: string[]): WireFlow {
return {
id,
label: `${names[0]} → ${names[names.length - 1]}`,
hops: names.map((name, i) => hop(name, { edge: i === 0 ? null : edge() })),
};
}
/* ---------------------------------------------------------------- tests -- */
describe('cardHeight', () => {
it('is the header plus one row per source line', () => {
expect(cardHeight(hop('a', { lines: 7 }))).toBe(HEADER_HEIGHT + 7 * CODE_LINE_HEIGHT + CODE_PADDING);
expect(cardHeight(hop('a', { lines: 1 }))).toBe(HEADER_HEIGHT + CODE_LINE_HEIGHT + CODE_PADDING);
});
it('gives a card with no source the height of the sentence that replaces it', () => {
expect(cardHeight(hop('a', { lines: 0 }))).toBe(HEADER_HEIGHT + NO_SOURCE_HEIGHT);
});
});
describe('dashFor', () => {
it('marks a synthesized hop `5 3` and an uncertain one `2 3`', () => {
expect(dashFor(edge({ synthesized: true }))).toBe('5 3');
expect(dashFor(edge({ uncertain: true }))).toBe('2 3');
expect(dashFor(edge())).toBeNull();
});
it('lets the synthesized pattern win, because it is the stronger claim', () => {
// A dynamic-dispatch bridge that also scored low confidence is still first
// and foremost a bridge: "we inferred this hop" is what a reader has to see.
expect(dashFor(edge({ synthesized: true, uncertain: true }))).toBe('5 3');
});
});
describe('labelLinesFor', () => {
it('leaves an ordinary call as one word', () => {
expect(labelLinesFor(edge())).toEqual(['calls']);
});
it('stacks a synthesized label and shortens the wiring site to a basename', () => {
expect(
labelLinesFor(
edge({ synthesized: true, label: 'via callback · registered at src/deep/nested/wire.ts:88' })
)
).toEqual(['via callback', 'registered at wire.ts:88']);
});
it('cuts anything still too wide for an 86px connector', () => {
const lines = labelLinesFor(edge({ label: 'via an extraordinarily long mechanism name' }));
expect(lines).toHaveLength(1);
expect(lines[0]!.length).toBe(LABEL_MAX_CHARS);
expect(lines[0]!.endsWith('…')).toBe(true);
});
});
describe('lineLabelFor', () => {
it('prints the recorded line, and nothing when there is none', () => {
expect(lineLabelFor(edge({ line: 2029 }))).toBe('line 2029');
expect(lineLabelFor(edge())).toBeNull();
expect(lineLabelFor(edge({ line: 0 }))).toBeNull();
});
});
describe('buildFlowLayout — one path', () => {
const single = flow('f1', ['a', 'b', 'c']);
it('puts one card per column, left to right, at the spec pitch', () => {
const layout = buildFlowLayout([single], 'f1');
expect(layout.cards.map((c) => c.hop.node.name)).toEqual(['a', 'b', 'c']);
expect(layout.cards.map((c) => c.column)).toEqual([0, 1, 2]);
expect(layout.cards.map((c) => c.x)).toEqual([PADDING, PADDING + COLUMN_PITCH, PADDING + 2 * COLUMN_PITCH]);
expect(COLUMN_PITCH).toBe(CARD_WIDTH + LINK_WIDTH);
});
it('places every card on one row and numbers its step on the active flow', () => {
const layout = buildFlowLayout([single], 'f1');
expect(new Set(layout.cards.map((c) => c.y)).size).toBe(1);
expect(layout.cards.map((c) => c.step)).toEqual([0, 1, 2]);
});
it('links consecutive cards and nothing else', () => {
const layout = buildFlowLayout([single], 'f1');
expect(layout.links.map((l) => [l.source, l.target])).toEqual([
['method:a', 'method:b'],
['method:b', 'method:c'],
]);
});
it('sizes the canvas to the cards it drew', () => {
const layout = buildFlowLayout([single], 'f1');
expect(layout.columns).toBe(3);
expect(layout.gaps).toEqual([LINK_WIDTH, LINK_WIDTH]);
expect(layout.width).toBe(PADDING * 2 + 3 * CARD_WIDTH + 2 * LINK_WIDTH);
expect(layout.height).toBe(PADDING * 2 + cardHeight(single.hops[0] as WireFlowHop));
});
it('widens the gap a long synthesized label has to fit into', () => {
// 86px holds `calls`; it does not hold `registered at App.tsx:3764`, which
// at a fixed pitch ran under the source of the card it was explaining.
const wired: WireFlow = {
id: 'f1',
label: 'a → b',
hops: [
hop('a', { edge: null }),
hop('b', {
edge: edge({
synthesized: true,
line: 5337,
label: 'via callback · onUpdate · registered at src/app/App.tsx:3764',
}),
}),
],
};
const layout = buildFlowLayout([wired], 'f1');
expect(layout.gaps[0]).toBeGreaterThan(LINK_WIDTH);
// Wide enough for the widest line it has to hold.
const widest = Math.max(...(layout.links[0]?.labelLines ?? []).map((l) => l.length));
expect(layout.gaps[0]).toBeGreaterThanOrEqual(widest * 6.65);
// …and the second card starts past it, so nothing is drawn over the label.
expect(layout.cards[1]?.x).toBe(PADDING + CARD_WIDTH + (layout.gaps[0] as number));
});
it('answers an empty picture for no flows at all', () => {
expect(buildFlowLayout([], null)).toEqual({
cards: [],
links: [],
width: 0,
height: 0,
columns: 0,
gaps: [],
});
});
});
describe('buildFlowLayout — two paths that merge', () => {
// a → b → d and a → c → d: the same start, the same end, different middles.
const left = flow('f1', ['a', 'b', 'd']);
const right = flow('f2', ['a', 'c', 'd']);
it('draws one DAG, not two strips', () => {
const layout = buildFlowLayout([left, right], 'f1');
expect(layout.cards).toHaveLength(4);
expect(layout.links).toHaveLength(4);
expect(layout.columns).toBe(3);
});
it('rejoins the shared cards in one column and stacks the branch', () => {
const layout = buildFlowLayout([left, right], 'f1');
const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
expect(at('a').column).toBe(0);
expect(at('d').column).toBe(2);
expect(at('b').column).toBe(1);
expect(at('c').column).toBe(1);
// Same column, different rows, exactly one gap apart.
expect(at('c').y - at('b').y).toBe(at('b').height + ROW_GAP);
});
it('records which paths a shared card and a branch link belong to', () => {
const layout = buildFlowLayout([left, right], 'f1');
const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
expect(at('a').flows).toEqual(['f1', 'f2']);
expect(at('b').flows).toEqual(['f1']);
expect(at('c').flows).toEqual(['f2']);
expect(layout.links.find((l) => l.target === 'method:c')!.flows).toEqual(['f2']);
});
it('marks the picked path, and only the picked path, with a step', () => {
const picked = buildFlowLayout([left, right], 'f2');
const at = (name: string) => picked.cards.find((c) => c.hop.node.name === name)!;
expect(at('c').step).toBe(1);
expect(at('b').step).toBe(-1);
// …and the picked path is the one drawn along the top of its columns.
expect(at('c').y).toBeLessThan(at('b').y);
});
});
describe('buildFlowLayout — awkward shapes', () => {
it('never draws a card left of something that calls it, on a long merge', () => {
// a → b → c → d and a → d: `d`'s column must come from the LONGEST route,
// or the short path would drag it back on top of `b`.
const long = flow('f1', ['a', 'b', 'c', 'd']);
const short = flow('f2', ['a', 'd']);
const layout = buildFlowLayout([long, short], 'f1');
const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
expect(at('d').column).toBe(3);
for (const link of layout.links) {
const from = layout.cards.find((c) => c.id === link.source)!;
const to = layout.cards.find((c) => c.id === link.target)!;
expect(to.column).toBeGreaterThan(from.column);
}
});
it('still draws every card when a flow calls back into itself', () => {
// a → b → a: a real shape (recursion through a helper) and one with no
// topological order. Nothing may vanish.
const cyclic: WireFlow = {
id: 'f1',
label: 'a → a',
hops: [hop('a', { edge: null }), hop('b'), { ...hop('a'), edge: edge() }],
};
const layout = buildFlowLayout([cyclic], 'f1');
expect(layout.cards.map((c) => c.hop.node.name).sort()).toEqual(['a', 'b']);
expect(layout.links).toHaveLength(2);
expect(layout.cards.every((c) => Number.isFinite(c.x) && Number.isFinite(c.y))).toBe(true);
});
it('centres a short column against a tall one', () => {
const tall = flow('f1', ['a', 'b', 'd']);
const alt = flow('f2', ['a', 'c', 'd']);
const layout = buildFlowLayout([tall, alt], 'f1');
const at = (name: string) => layout.cards.find((c) => c.hop.node.name === name)!;
const columnMiddle = (name: string) => at(name).y + at(name).height / 2;
// `a` is alone in its column; `b`/`c` share the next one. Their midpoints line up.
expect(columnMiddle('a')).toBeCloseTo((at('b').y + at('c').y + at('c').height) / 2, 5);
});
});
+12 -2
View File
@@ -124,16 +124,26 @@ describe('the palette', () => {
expect(interleaveResults(a, [result({ id: 'a2' })]).map((r) => r.id)).toEqual(['a1', 'a2']);
});
it('explains that a flow question is answered by both endpoints for now', () => {
it('offers the flow FIRST for a flow question, then what each name matches', () => {
const palette = buildSearchPalette(
[answer([result({ id: 'a', name: 'sync' })]), answer([result({ id: 'b', name: 'read' })])],
{ from: 'sync', to: 'read' }
);
expect(palette.items.map((i) => i.id)).toEqual(['a', 'b']);
// First row, so Enter opens the path: the question asked for the path.
expect(palette.sections[0]?.title).toBe('Flow');
expect(palette.items[0]).toMatchObject({ type: 'flow', from: 'sync', to: 'read' });
expect(palette.items.map((i) => i.id).slice(1)).toEqual(['a', 'b']);
expect(palette.items).toEqual(palette.sections.flatMap((s) => s.items));
expect(palette.hint).toContain('sync');
expect(palette.hint).toContain('read');
});
it('offers no flow row when the query is not a flow question', () => {
const palette = buildSearchPalette([answer([result({ id: 'a' })])], null);
expect(palette.sections.some((s) => s.title === 'Flow')).toBe(false);
expect(palette.items.every((i) => i.type !== 'flow')).toBe(true);
});
it('names a kind bucket in sentence case, singular when there is one', () => {
expect(kindGroupTitle('method', 3)).toBe('Methods');
expect(kindGroupTitle('method', 1)).toBe('Method');