Files
codegraph/__tests__/ui-program-model.test.ts
Colby MchenryandClaude Opus 5.5 81e08b76ed fix(viewer): pages that went blank — repeated ids, and Steps on a function full of checks (#2264)
* fix(viewer): a repeated id no longer blanks Entry points or a component's Symbol view

The viewer keys its lists by id and a repeat stops the page drawing. The
routes list was a row per (route, edge), so an inline handler came back once
per call in its body; it is now one row per route — its bound handler, or
the route itself as an inline handler. A Vue/Svelte/Astro component's
members listed its script's symbols twice, plus the component and its file;
each member is now listed once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(viewer): Steps reads a function full of checks in one pass

The in-order reading kept every way forward through an `if` with no
`else` twice — under its condition and without — so eighty checks that
draw nothing (jsoup's `parse`) made 2^80 ways and the page died on a
stack overflow. Ways waiting at the same place are merged, keeping the
conditions they all share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:23:31 +00:00

361 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The Steps picture in the code's order: the graph of what happens next.
*
* The server folds the walk into blocks and forks (`api/program.ts`); this
* turns that into the canvas's graph — one edge per "and then", carrying the
* condition where the code branched, and a row per step counted by how much
* has to happen before it. What is pinned here is exactly that: the shape of
* the picture, which is the thing a reader looks at.
*/
import { describe, it, expect } from 'vitest';
import { buildOrderModel, lineWords, orderGraph, runWords } from '../ui/src/lib/program-model';
import { selectionReach, stepEdgeVisible } from '../ui/src/lib/steps-model';
import { placeLabels } from '../ui/src/lib/screens-model';
import type { WireArm, WireBlock, WireItem, WireProgram, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
/* ------------------------------------------------------------ material -- */
const step = (id: string, over: Partial<WireStep> = {}): WireStep => ({
id,
kind: 'effect',
anchor: false,
node: null,
label: id,
sub: 'response · handler',
depth: 1,
cut: null,
...over,
});
const arm = (when: string, body: WireBlock, over: Partial<WireArm> = {}): WireArm => ({ when, ends: null, body, ...over });
function payload(steps: WireStep[], root: WireBlock): WireStepsPayload {
return {
anchor: { id: 'anchor', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false },
ambiguous: [],
project: 'api',
steps: [step('anchor', { kind: 'anchor', anchor: true, label: 'POST /login' }), ...steps],
links: [],
program: { root, truncated: 0 },
defaultView: 'order',
depth: 8,
limit: 120,
through: false,
truncated: { steps: 0, hubs: 0, chrome: 0 },
index: { lastIndexedAt: null, edges: 0, files: 0 },
timing: { elapsedMs: 1 },
};
}
/** The graph as `from → to` lines, each with what has to hold. */
function shape(root: WireBlock): string[] {
const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
return g.edges.map((e) => `${e.from} → ${e.to}${e.when ? ` · ${lineWords(e)}` : ''}${e.runs.length ? ` [${e.runs.join(', ')}]` : ''}`);
}
function rowsOf(root: WireBlock): Record<string, number> {
const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
return Object.fromEntries(g.depth);
}
/* --------------------------------------------------------------- tests -- */
describe('the picture in the code’s order', () => {
it('puts one step after the next', () => {
expect(shape([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual(['anchor → a', 'a → b']);
expect(rowsOf([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual({ anchor: 0, a: 1, b: 2 });
});
it('diverges both arms from a point that asks the condition once', () => {
// proshop's login: look the user up, then sign+answer 200, else answer 401.
// The decision is ONE choice, so it draws once — a point the arms leave,
// each line saying only which arm it is — not two lines that each carry
// the whole predicate, one of them negated.
const on = 'user && (await user.matchPassword(password))';
const root: WireBlock = [
{ kind: 'step', step: 'findOne' },
{
kind: 'fork',
form: 'if',
on,
arms: [
arm(on, [{ kind: 'block', block: 'inline', body: [{ kind: 'step', step: 'sign' }] }, { kind: 'step', step: '200' }], { ends: 'reply' }),
arm(`!(${on})`, [{ kind: 'step', step: '401' }], { not: true, ends: 'reply' }),
],
},
];
const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
expect(g.forks).toEqual([{ id: 'fork:0', on, form: 'if' }]);
expect(shape(root)).toEqual([
'anchor → findOne',
'findOne → fork:0',
'fork:0 → sign · yes [via a helper]',
'sign → 200',
'fork:0 → 401 · no',
]);
// The arm's own condition still rides the line, for the hover.
expect(g.edges.find((e) => e.to === '401')!.when).toBe(`!(${on})`);
// The 200 sits a row BELOW the signing, which is the whole point; the
// decision takes a row of its own between the lookup and the arms.
expect(rowsOf(root)).toEqual({ anchor: 0, findOne: 1, 'fork:0': 2, sign: 3, '200': 4, '401': 3 });
});
it('rejoins after an arm that runs on, and stops at one that ends', () => {
const root: WireBlock = [
{ kind: 'step', step: 'lookup' },
{
kind: 'fork',
form: 'if',
on: 'ready',
arms: [arm('ready', [{ kind: 'step', step: 'inside' }]), arm('!ready', [{ kind: 'step', step: 'bail' }], { not: true, ends: 'return' })],
},
{ kind: 'step', step: 'after' },
];
expect(shape(root)).toEqual([
'anchor → lookup',
'lookup → fork:0',
'fork:0 → inside · yes',
'fork:0 → bail · no',
'inside → after',
]);
});
it('labels a switch’s arms with their own values, and its default with else', () => {
const root: WireBlock = [
{ kind: 'step', step: 'load' },
{
kind: 'fork',
form: 'switch',
on: 'status',
arms: [
arm("status === 'expired'", [{ kind: 'step', step: 'refresh' }]),
arm("status === 'active'", [{ kind: 'step', step: 'serve' }]),
arm("!(status === 'expired' || status === 'active')", [{ kind: 'step', step: 'reject' }], { not: true, ends: 'reply' }),
],
},
];
expect(shape(root)).toEqual([
'anchor → load',
'load → fork:0',
"fork:0 → refresh · 'expired'",
"fork:0 → serve · 'active'",
'fork:0 → reject · else',
]);
});
it('keeps a lone guard on the line — an early exit is not a point', () => {
// `if (!product) throw` — the exit arm is empty; only one arm draws, so
// the condition rides the line exactly as before.
const root: WireBlock = [
{ kind: 'step', step: 'lookup' },
{
kind: 'fork',
form: 'if',
on: 'product',
arms: [arm('product', [], { ends: 'throw' }), arm('!product', [{ kind: 'step', step: 'render' }], { not: true })],
},
];
const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
expect(g.forks).toEqual([]);
expect(shape(root)).toEqual(['anchor → lookup', 'lookup → render · WHEN NOT product']);
});
it('stops claiming a side when both arms reach the same step', () => {
const root: WireBlock = [
{ kind: 'step', step: 'check' },
{
kind: 'fork',
form: 'if',
on: 'a',
arms: [
arm('a', [{ kind: 'step', step: 'log' }, { kind: 'step', step: 'go' }]),
arm('!(a)', [{ kind: 'step', step: 'log', again: true }], { not: true }),
],
},
];
const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
const toLog = g.edges.find((e) => e.to === 'log')!;
expect(toLog.arm).toBeUndefined();
expect(toLog.when).toBe('a || !(a)');
});
it('runs on either way past an `if` with no else', () => {
const root: WireBlock = [
{ kind: 'step', step: 'lookup' },
{ kind: 'fork', form: 'if', on: 'verified', arms: [arm('verified', [{ kind: 'step', step: 'mail' }])] },
{ kind: 'step', step: 'reply' },
];
expect(shape(root)).toEqual([
'anchor → lookup',
'lookup → mail · WHEN verified',
'mail → reply',
'lookup → reply',
]);
});
it('reads on into what a step sets in motion before the next step', () => {
const root: WireBlock = [
{ kind: 'step', step: 'save', body: [{ kind: 'step', step: 'write' }] },
{ kind: 'step', step: 'reply' },
];
expect(shape(root)).toEqual(['anchor → save', 'save → write', 'write → reply']);
});
it('says the run a line happens inside', () => {
const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
expect(shape([{ kind: 'block', block: 'inline', via, body: [{ kind: 'step', step: 'sign' }] }])).toEqual([
'anchor → sign [via generateToken]',
]);
expect(shape([{ kind: 'block', block: 'loop', by: 'item of items', loop: 'each', body: [{ kind: 'step', step: 'save' }] }])).toEqual([
'anchor → save [for each item of items]',
]);
});
it('carries on past a helper that answers on every path', () => {
// express-realworld: `login()` throws on each guard and returns on one; the
// handler's own `res.json` still follows the call.
const root: WireBlock = [
{
kind: 'block',
block: 'inline',
body: [{ kind: 'fork', form: 'if', on: 'bad', arms: [arm('bad', [{ kind: 'step', step: '422' }], { ends: 'reply' })] }],
},
{ kind: 'step', step: '200' },
];
expect(shape(root)).toEqual(['anchor → 422 · WHEN bad [via a helper]', 'anchor → 200']);
});
it('lets nothing float: a step the fold could not place follows the anchor', () => {
const g = orderGraph({ root: [{ kind: 'cut', why: 'folded' }], truncated: 1 } as WireProgram, 'anchor');
expect(g.edges).toEqual([]);
});
it('settles the rows of a step reached twice rather than looping', () => {
const root: WireBlock = [{ kind: 'step', step: 'db' }, { kind: 'step', step: 'check' }, { kind: 'step', step: 'db' }];
expect(shape(root)).toEqual(['anchor → db', 'db → check', 'check → db']);
expect(rowsOf(root)).toEqual({ anchor: 0, db: 1, check: 2 });
});
it('never spreads a cyclic reading over more rows than it has boxes', () => {
// A helper the code comes back to from inside a decision makes the graph
// cyclic. Relaxing over a cycle never settles — it added a row on every
// pass until the bound, so on a real screen sixteen boxes landed on sixty
// rows and the picture was a 9,000px ribbon of empty space that no fit
// could open on.
const root: WireBlock = [
{ kind: 'step', step: 'logout' },
{ kind: 'step', step: 'flags' },
{
kind: 'fork',
form: 'if',
on: 'options?.showAlert',
arms: [
arm('options?.showAlert', [{ kind: 'step', step: 'logout', again: true }]),
arm('!options?.showAlert', [{ kind: 'step', step: 'quiet' }], { not: true }),
],
},
];
const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
// The cycle is real and still drawn — it is only the ROW that ignores it.
expect(g.edges.some((e) => e.to === 'logout' && e.from.startsWith('fork:'))).toBe(true);
const depths = [...g.depth.values()];
expect(Math.max(...depths)).toBeLessThan(g.depth.size);
// Every row between the top and the deepest holds something.
expect(new Set(depths).size).toBe(Math.max(...depths) + 1);
});
it('names each kind of run', () => {
const via = { id: 'f', kind: 'function' as const, name: 'gen', qualifiedName: 'gen', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
const block = (over: Partial<Extract<WireItem, { kind: 'block' }>>) => runWords({ kind: 'block', block: 'inline', body: [], ...over } as Extract<WireItem, { kind: 'block' }>);
expect(block({ via })).toBe('via gen');
expect(block({})).toBe('via a helper');
expect(block({ block: 'later', by: 'then' })).toBe('later · then');
expect(block({ block: 'loop', by: 'item of items', loop: 'each' })).toBe('for each item of items');
expect(block({ block: 'loop', by: 'queue.length', loop: 'while' })).toBe('again while queue.length');
expect(block({ block: 'together', by: 'Promise.all' })).toBe('together · Promise.all');
});
it('builds a picture the canvas can draw, and nothing when there is no body', () => {
const model = buildOrderModel(
payload([step('findOne'), step('200')], [{ kind: 'step', step: 'findOne' }, { kind: 'step', step: '200' }])
);
expect(model).not.toBeNull();
expect([...model!.nodes.keys()].sort()).toEqual(['200', 'anchor', 'findOne']);
expect(model!.layout.nodes).toHaveLength(3);
// The anchor is on top: layer 0 is the bottom.
const layer = (id: string) => model!.layout.nodes.find((n) => n.id === id)!.layer;
expect(layer('anchor')).toBeGreaterThan(layer('findOne'));
expect(layer('findOne')).toBeGreaterThan(layer('200'));
expect(buildOrderModel({ ...payload([], []), program: null })).toBeNull();
});
it('draws a decision as a point, and the selection reaches through it', () => {
const root: WireBlock = [
{ kind: 'step', step: 'lookup' },
{
kind: 'fork',
form: 'if',
on: 'ready',
arms: [arm('ready', [{ kind: 'step', step: 'inside' }]), arm('!ready', [{ kind: 'step', step: 'bail' }], { not: true, ends: 'return' })],
},
];
const model = buildOrderModel(payload([step('lookup'), step('inside'), step('bail')], root))!;
expect(model.forks!.get('fork:0')).toEqual({ id: 'fork:0', on: 'ready', form: 'if', label: 'ready?' });
// The point sits between the step before the fork and the arms; it is not a step.
const at = (id: string) => model.layout.nodes.find((n) => n.id === id)!;
expect(at('fork:0').y).toBeGreaterThan(at('lookup').y);
expect(at('fork:0').y).toBeLessThan(at('inside').y);
expect(model.nodes.has('fork:0')).toBe(false);
expect(model.counts.effect).toBe(3);
// The lines out of it say the arm; the line into it says nothing.
const label = (to: string) => [...model.edges.values()].find((e) => e.to === to)!.label;
expect(label('fork:0')).toBe('');
expect(label('inside')).toBe('yes');
expect(label('bail')).toBe('no');
// At rest the arms are labelled — the conditions are this picture's content.
const pills = placeLabels(model, null, true);
expect([...pills.pills.values()].map((p) => p.text).sort()).toEqual(['→ no', '→ yes']);
// Selecting the step before the decision reaches through the point: the
// arms' lines light, instead of dying at a box the reader cannot click.
const reach = selectionReach(model, 'lookup');
expect(reach.has('fork:0')).toBe(true);
const armEdge = model.layout.edges.find((e) => e.source === 'fork:0' && e.target === 'inside')!;
expect(stepEdgeVisible(model, armEdge, 'lookup')).toBe(true);
expect(stepEdgeVisible(model, armEdge, 'lookup', reach)).toBe(true);
// …and selecting an arm lights its sibling, through the same point.
const sibling = model.layout.edges.find((e) => e.source === 'fork:0' && e.target === 'bail')!;
expect(stepEdgeVisible(model, sibling, 'inside')).toBe(true);
});
});
describe('a body full of checks that draw nothing', () => {
it('reads in one pass, not one per combination of checks', () => {
// jsoup's `parse` inlines eighty-odd `if`s with no `else` and no step in
// them; each one doubled every way on, and the page died overflowing the
// stack. They are all one way on, under no condition of their own.
const checks: WireItem[] = Array.from({ length: 80 }, (_, i) => ({
kind: 'fork',
form: 'if',
on: `c${i}`,
arms: [arm(`c${i}`, [])],
}));
const root: WireBlock = [{ kind: 'step', step: 'a' }, ...checks, { kind: 'step', step: 'b' }];
expect(shape(root)).toEqual(['anchor → a', 'a → b']);
expect(buildOrderModel(payload([step('a'), step('b')], root))?.nodes.size).toBe(3);
});
it('claims no side when the code goes on either way', () => {
const root: WireBlock = [
{
kind: 'fork',
form: 'if',
on: 'user',
arms: [arm('user', [{ kind: 'fork', form: 'if', on: 'admin', arms: [arm('admin', [])] }])],
},
{ kind: 'step', step: 'a' },
];
expect(shape(root)).toEqual(['anchor → a']);
});
});