feat(steps): the rail — a handler read top to bottom, forks and all
The reading the walk records now has a picture. `#/steps?…&view=order` draws the anchor, then its body: a box per step in the order the code writes them, a fork where the code forks with its arms side by side under the condition, a helper drawn where it is called, and an arm that answers, returns or throws ending there — so proshop's login reads *look the user up · if the password matches, sign a token inside the reply and answer 200 · otherwise 401*, which is what the code says and what a row of four boxes could not. - `program-model.ts` decides the words: the fork carries the decision once and its arms say only which side they are (WHEN / WHEN NOT), except a `switch`, whose arms each have a case to say, and a `try`, which says `on error` once. - `StepBox.svelte` is the box both readings draw — the canvas wraps it in handles, the rail lets it size to its words. Same look, same click, same double-click-to-start-here. - `StepsKey.svelte` is the key, floating over the canvas as before and last in the document on the rail, which scrolls and cannot have things sitting on it. - The reading travels in the URL (`view=order` / `view=tree`) and the summary offers both; without one, the answer's own default decides — the code's order for a handler or an endpoint, the tree for a screen. 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
b02e192ffa
commit
9acab0020f
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 },
|
||||
ambiguous: [],
|
||||
project: 'api',
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
const arm = (when: string, over: Partial<WireArm> = {}): WireArm => ({ when, ends: null, body: [], ...over });
|
||||
|
||||
describe('the rail’s words', () => {
|
||||
it('says the decision once, and each arm only which side it is', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
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('is empty when there is no body to read', () => {
|
||||
expect(buildRailModel({ ...payload([], []), program: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ function shape(block: WireBlock, indent = ''): string[] {
|
||||
out.push(...shape(arm.body, `${indent} `));
|
||||
}
|
||||
} else if (item.kind === 'block') {
|
||||
out.push(`${indent}${item.block} ${item.label}${item.again ? ' (again)' : ''}`);
|
||||
out.push(`${indent}${item.block}${item.via ? ` via ${item.via.name}` : item.by ? ` ${item.by}` : ''}${item.again ? ' (again)' : ''}`);
|
||||
out.push(...shape(item.body, `${indent} `));
|
||||
} else out.push(`${indent}cut ${item.why}`);
|
||||
}
|
||||
@@ -157,8 +157,9 @@ describe('buildProgram', () => {
|
||||
at('d', [g('kind: default', { form: 'case', branch })]),
|
||||
],
|
||||
});
|
||||
// The head says what is being decided on; each arm its own case.
|
||||
expect(shape(p!.root)).toEqual([
|
||||
"switch kind === 'a'",
|
||||
'switch kind',
|
||||
" arm kind === 'a'",
|
||||
' a',
|
||||
" arm kind === 'b'",
|
||||
@@ -224,14 +225,14 @@ describe('buildProgram', () => {
|
||||
const p = program({
|
||||
root: [at('now'), at('afterwards', [], { trigger: { kind: 'callback', name: 'then', of: null } })],
|
||||
});
|
||||
expect(shape(p!.root)).toEqual(['now', 'later later · then', ' afterwards']);
|
||||
expect(shape(p!.root)).toEqual(['now', 'later then', ' afterwards']);
|
||||
});
|
||||
|
||||
it('puts calls started together in one block', () => {
|
||||
const p = program({
|
||||
root: [at('a', [], { within: 'Promise.all' }), at('b', [], { within: 'Promise.all' }), at('c')],
|
||||
});
|
||||
expect(shape(p!.root)).toEqual(['together together', ' a inside Promise.all', ' b inside Promise.all', 'c']);
|
||||
expect(shape(p!.root)).toEqual(['together Promise.all', ' a inside Promise.all', ' b inside Promise.all', 'c']);
|
||||
});
|
||||
|
||||
it('closes a fork when the code leaves it', () => {
|
||||
|
||||
Reference in New Issue
Block a user