feat(steps): the order reading is the canvas, not a rail
The first cut drew the code's order as a nested document — a column of boxes, forks as rows of arm columns. Wrong picture: hard to read, and it threw away the thing that made the tree legible. The ask was the canvas back, with the timing fixed: the 200 comes after the token is signed, so it should branch out of it. So the order reading is now the SAME canvas, the same boxes, the same pills, hover and panel — only the graph changes. `ui/src/lib/program-model.ts` walks the server's block tree carrying a set of tails (the steps a next step would follow) and emits one edge per "and then": proshop's login draws the anchor, `User.findOne`, then the fork — `jwt.sign` under one arm with the `200` a row below it, the `401` under the other. A row down is one more thing that has already happened; an arm that answers, returns or throws has nothing leaving it; a helper, a loop, `later` and `together` ride on the line into what they hold. Rows are settled by relaxation, because a step reached twice can make the graph cyclic. A line means "and then" here and "leads to" in the tree, so the key says which. The fork conditions are drawn at rest rather than only for a selected box — `placeLabels` takes an `atRest` flag — because on this picture they are the content, and two ways to one step merge as one condition (`WHEN userExists OR NOT user`), not as two rendered labels stuck together. `StepsRail.svelte` and `RailBlock.svelte` are gone; `StepBox.svelte` stays as the box both readings draw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01REFyW9hmNrxhwN5wxRoAkC
This commit is contained in:
co-authored by
Claude Opus 5
parent
75686502e3
commit
209a07e881
@@ -1,13 +1,18 @@
|
||||
/**
|
||||
* What the rail SAYS. The block tree the server sends is turned into rows of
|
||||
* boxes and words here (`ui/src/lib/program-model.ts`); this pins the words —
|
||||
* which is the part a reader actually meets.
|
||||
* 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 { joinTokens } from '../ui/src/lib/conditions';
|
||||
import { armWords, buildRailModel, endWords, groupLabel } from '../ui/src/lib/program-model';
|
||||
import type { WireArm, WireBlock, WireItem, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
|
||||
import { buildOrderModel, lineWords, orderGraph, runWords } from '../ui/src/lib/program-model';
|
||||
import type { WireArm, WireBlock, WireItem, WireProgram, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
|
||||
|
||||
/* ------------------------------------------------------------ material -- */
|
||||
|
||||
const step = (id: string, over: Partial<WireStep> = {}): WireStep => ({
|
||||
id,
|
||||
@@ -15,18 +20,20 @@ const step = (id: string, over: Partial<WireStep> = {}): WireStep => ({
|
||||
anchor: false,
|
||||
node: null,
|
||||
label: id,
|
||||
sub: `response · handler`,
|
||||
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: 'a', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false },
|
||||
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,
|
||||
steps: [step('anchor', { kind: 'anchor', anchor: true, label: 'POST /login' }), ...steps],
|
||||
links: [],
|
||||
program: { root, truncated: 0 },
|
||||
defaultView: 'order',
|
||||
@@ -39,94 +46,148 @@ function payload(steps: WireStep[], root: WireBlock): WireStepsPayload {
|
||||
};
|
||||
}
|
||||
|
||||
const arm = (when: string, over: Partial<WireArm> = {}): WireArm => ({ when, ends: null, body: [], ...over });
|
||||
/** 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(', ')}]` : ''}`);
|
||||
}
|
||||
|
||||
describe('the rail’s words', () => {
|
||||
it('says the decision once, and each arm only which side it is', () => {
|
||||
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('branches both arms off the step before the fork, and says what has to hold', () => {
|
||||
// proshop's login: look the user up, then sign+answer 200, else answer 401.
|
||||
const on = 'user && (await user.matchPassword(password))';
|
||||
const fork: WireItem = {
|
||||
kind: 'fork',
|
||||
form: 'if',
|
||||
on,
|
||||
arms: [arm(on, { ends: 'reply', body: [{ kind: 'step', step: '200' }] }), arm(`!(${on})`, { not: true, ends: 'reply', body: [{ kind: 'step', step: '401' }] })],
|
||||
};
|
||||
const model = buildRailModel(payload([step('200'), step('401')], [fork]));
|
||||
expect(model).toHaveLength(1);
|
||||
const rail = model[0]!;
|
||||
if (rail.kind !== 'fork') throw new Error('expected a fork');
|
||||
expect(joinTokens(rail.words)).toBe('user AND (await user.matchPassword(password))');
|
||||
expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(['WHEN', 'WHEN NOT']);
|
||||
expect(rail.arms.map((a) => a.ends)).toEqual(['answers here', 'answers here']);
|
||||
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' }),
|
||||
],
|
||||
},
|
||||
];
|
||||
expect(shape(root)).toEqual([
|
||||
'anchor → findOne',
|
||||
'findOne → sign · WHEN user AND (await user.matchPassword… [via a helper]',
|
||||
'sign → 200',
|
||||
'findOne → 401 · WHEN NOT (user && (await user.matchPass…',
|
||||
]);
|
||||
// The 200 sits a row BELOW the signing, which is the whole point.
|
||||
expect(rowsOf(root)).toEqual({ anchor: 0, findOne: 1, sign: 2, '200': 3, '401': 2 });
|
||||
});
|
||||
|
||||
it('keeps a disjunction whole rather than reading it as two ways of arriving', () => {
|
||||
// `!image || unlimitedCollection` is ONE condition, and the parentheses
|
||||
// `guardLabel` puts round it are what stop the OR from splitting it.
|
||||
const on = '(!image || unlimitedCollection)';
|
||||
const model = buildRailModel(payload([], [{ kind: 'fork', form: 'if', on, arms: [arm(on)] }]));
|
||||
const rail = model[0]!;
|
||||
if (rail.kind !== 'fork') throw new Error('expected a fork');
|
||||
expect(joinTokens(rail.words)).toBe('(!image || unlimitedCollection)');
|
||||
});
|
||||
|
||||
it('gives a switch’s arms their own conditions', () => {
|
||||
const fork: WireItem = {
|
||||
kind: 'fork',
|
||||
form: 'switch',
|
||||
on: 'kind',
|
||||
arms: [arm("kind === 'a'"), arm('kind: default')],
|
||||
};
|
||||
const model = buildRailModel(payload([], [fork]));
|
||||
const rail = model[0]!;
|
||||
if (rail.kind !== 'fork') throw new Error('expected a fork');
|
||||
expect(joinTokens(rail.words)).toBe('kind');
|
||||
expect(rail.arms.map((a) => joinTokens(a.words))).toEqual(["WHEN kind === 'a'", 'WHEN kind: default']);
|
||||
});
|
||||
|
||||
it('lets a try say `on error` once', () => {
|
||||
expect(armWords('try', 'on error', arm('on error'))).toEqual([]);
|
||||
const model = buildRailModel(payload([], [{ kind: 'fork', form: 'try', on: 'on error', arms: [arm('on error')] }]));
|
||||
const rail = model[0]!;
|
||||
if (rail.kind !== 'fork') throw new Error('expected a fork');
|
||||
expect(joinTokens(rail.words)).toBe('on error');
|
||||
});
|
||||
|
||||
it('says how each arm leaves', () => {
|
||||
expect(endWords('reply')).toBe('answers here');
|
||||
expect(endWords('return')).toBe('returns here');
|
||||
expect(endWords('throw')).toBe('throws here');
|
||||
expect(endWords('exit')).toBe('leaves here');
|
||||
});
|
||||
|
||||
it('names each kind of bracketed run', () => {
|
||||
const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
|
||||
expect(groupLabel({ kind: 'block', block: 'inline', via, body: [] })).toBe('via generateToken');
|
||||
expect(groupLabel({ kind: 'block', block: 'inline', body: [] })).toBe('via a helper');
|
||||
expect(groupLabel({ kind: 'block', block: 'later', by: 'then', body: [] })).toBe('later · then');
|
||||
expect(groupLabel({ kind: 'block', block: 'loop', by: 'item of items', body: [] })).toBe('for each item of items');
|
||||
expect(groupLabel({ kind: 'block', block: 'together', by: 'Promise.all', body: [] })).toBe('together · Promise.all');
|
||||
});
|
||||
|
||||
it('carries a box’s two lines and where the call is written', () => {
|
||||
const model = buildRailModel(
|
||||
payload([step('200', { label: '200', sub: 'response · authUser' })], [{ kind: 'step', step: '200', within: 'res.json' }])
|
||||
);
|
||||
const rail = model[0]!;
|
||||
if (rail.kind !== 'step') throw new Error('expected a step');
|
||||
expect(rail.info?.label).toBe('200');
|
||||
expect(rail.info?.sub).toBe('response · authUser');
|
||||
expect(rail.within).toBe('res.json');
|
||||
});
|
||||
|
||||
it('says where the reading stopped', () => {
|
||||
const model = buildRailModel(payload([], [{ kind: 'cut', why: 'folded' }, { kind: 'cut', why: 'depth' }]));
|
||||
expect(model.map((i) => (i.kind === 'cut' ? i.text : ''))).toEqual([
|
||||
'reads back into itself — the rest is the same code again',
|
||||
'as deep as this reading goes — start at a step below to read on',
|
||||
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 → inside · WHEN ready',
|
||||
'lookup → bail · WHEN NOT ready',
|
||||
'inside → after',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is empty when there is no body to read', () => {
|
||||
expect(buildRailModel({ ...payload([], []), program: null })).toEqual([]);
|
||||
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).db).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user