feat(steps): render fork decisions as points with per-arm edges and captions

Adds full support for decisions at forks in both the code graph and the UI. Key changes introduce a decision model for forks (innermost guard decisions), propagate decision data through the server and wire layer, and render decisions in the UI as distinct points with labeled arms. New components (ForkPoint and DecisionCaption) visualize the decision and its arms, while utilities (armWords, forkLabel) generate arm captions. The order reading (canvas) now shows decisions as points, and arms are drawn as separate edges (yes/no/case), with labels and captions displayed under the deciding box. Tests, typings, and docs updated to reflect the new decision visualization and behavior, including selection reach and resting-label semantics. This lays the groundwork for clearer visualization of conditional navigation and guarded branches on the order canvas.
This commit is contained in:
Colby McHenry
2026-08-31 17:10:13 -05:00
parent 882ea143e8
commit 3298db1292
14 changed files with 1006 additions and 104 deletions
+15
View File
@@ -580,6 +580,21 @@ describe('expo-router: end-to-end', () => {
expect(toRoot.when).not.toMatch(/!/);
expect(toWelcome.when).toMatch(/!\s*\(?\s*await seen\(\)/);
// …and each site names the DECISION its condition belongs to, so the two
// arms can be drawn as one choice rather than as two lines that happen to
// read as each other's negation. Same branch, opposite arms, one `on`.
const rootArm = toRoot.sites[0]!.decision!;
const welcomeArm = toWelcome.sites[0]!.decision!;
expect(rootArm.branch).toBe(welcomeArm.branch);
expect(rootArm.branch).not.toBe('');
expect(rootArm.form).toBe('ternary');
expect(rootArm.on).toBe(welcomeArm.on);
expect(rootArm.on).toMatch(/await seen\(\)/);
expect(rootArm.on).not.toMatch(/^!/);
expect(rootArm.not).toBeUndefined();
expect(welcomeArm.not).toBe(true);
expect(rootArm.arm).not.toBe(welcomeArm.arm);
cg.close();
});
});
+145 -8
View File
@@ -10,6 +10,8 @@
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 -- */
@@ -65,8 +67,11 @@ describe('the picture in the codes order', () => {
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', () => {
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' },
@@ -80,14 +85,20 @@ describe('the picture in the codes order', () => {
],
},
];
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 → sign · WHEN user AND (await user.matchPassword… [via a helper]',
'findOne → fork:0',
'fork:0 → sign · yes [via a helper]',
'sign → 200',
'findOne → 401 · WHEN NOT (user && (await user.matchPass…',
'fork:0 → 401 · no',
]);
// 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 });
// 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', () => {
@@ -103,12 +114,72 @@ describe('the picture in the codes order', () => {
];
expect(shape(root)).toEqual([
'anchor → lookup',
'lookup → inside · WHEN ready',
'lookup → bail · WHEN NOT ready',
'lookup → fork:0',
'fork:0 → inside · yes',
'fork:0 → bail · no',
'inside → after',
]);
});
it('labels a switchs 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' },
@@ -163,7 +234,35 @@ describe('the picture in the codes order', () => {
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);
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', () => {
@@ -190,4 +289,42 @@ describe('the picture in the codes order', () => {
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);
});
});
+91 -2
View File
@@ -4,9 +4,9 @@
* rule, and the panel's two lists.
*/
import { describe, it, expect } from 'vitest';
import { buildStepsModel, countWords, kindWord, kindWords, stepEdgeVisible, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
import { armWords, buildStepsModel, countWords, kindWord, kindWords, stepEdgeVisible, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
import { placeLabels } from '../ui/src/lib/screens-model';
import type { WireNodeRef, WireStep, WireStepLink, WireStepsPayload } from '../ui/src/lib/wire';
import type { WireNodeRef, WireStep, WireStepLink, WireStepSite, WireStepsPayload } from '../ui/src/lib/wire';
function ref(name: string, file = 'src/a.tsx', language: WireNodeRef['language'] = 'tsx'): WireNodeRef {
return { id: `function:${name}`, kind: 'function', name, qualifiedName: name, file, line: 1, endLine: 9, language, test: false };
@@ -117,6 +117,95 @@ describe('steps model', () => {
});
});
describe('a decision drawn where it is made', () => {
// The real shape this exists for: `return (await hasSeenWelcome(id)) ?
// '/home/' : '/welcome/'` inside a store action, whose two returned routes
// are two `navigates` edges out of ONE box. Each carried the whole
// predicate — one of them the other's negation — and at rest the tree drew
// both with no label at all, so nothing said it was a choice.
const ON = 'await hasSeenWelcome(welcomeUserId())';
const BRANCH = '140:9';
const site = (when: string, not?: true): WireStepSite => ({
file: 'src/org-user.storage.ts',
line: 140,
text: `push ${when}`,
when,
decision: { branch: BRANCH, on: ON, arm: when, form: 'ternary', ...(not ? { not: true as const } : {}) },
});
const anchor = step('/terms-of-service', 'screen', 0, { anchor: true });
const resolve = step('resolvePostLoginRoute', 'store', 1, { node: ref('resolvePostLoginRoute', 'src/org-user.storage.ts') });
const home = step('/home', 'screen', 2, { screen: { path: '/home', component: null } });
const welcome = step('/welcome', 'screen', 2, { screen: { path: '/welcome', component: null } });
const links = [
link(anchor, resolve, { kind: 'store' }),
link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON)] }),
link(resolve, welcome, { kind: 'navigates', when: `!(${ON})`, sites: [site(`!(${ON})`, true)] }),
];
const model = buildStepsModel(payload([anchor, resolve, home, welcome], links));
const edgeTo = (id: string) => [...model.edges.values()].find((e) => e.to === id)!;
it('says the condition once, under the box that decides it', () => {
expect(model.decisions).toHaveLength(1);
const d = model.decisions[0]!;
expect(d.label).toBe('await hasSeenWelcome(welcomeUserId())?');
// Under the deciding box and centred on it — not under the arms. The
// condition may take more room than the box, since reading it is the
// whole point of the caption.
const box = model.layout.nodes.find((n) => n.id === resolve.id)!;
expect(d.x + d.width / 2).toBeCloseTo(box.x + box.width / 2, 5);
expect(d.width).toBeGreaterThanOrEqual(box.width);
expect(d.y).toBeGreaterThan(box.y + box.height - 1);
});
it('each line out answers, instead of carrying the whole predicate', () => {
expect(edgeTo(home.id).arm).toBe('yes');
expect(edgeTo(home.id).label).toBe('yes');
expect(edgeTo(welcome.id).arm).toBe('no');
expect(edgeTo(welcome.id).label).toBe('no');
// The line into the deciding box is not an arm of anything.
expect(edgeTo(resolve.id).arm).toBeUndefined();
});
it('labels the arms at rest — and only the arms', () => {
const arms = new Set([...model.edges.values()].filter((e) => e.arm !== undefined).map((e) => e.id));
const pills = placeLabels(model, null, arms);
expect([...pills.pills.values()].map((p) => p.text).sort()).toEqual(['→ no', '→ yes']);
// With nothing asked for, the tree stays unlabelled as it always was.
expect(placeLabels(model, null, false).pills.size).toBe(0);
});
it('keeps a lone arm, and a step reached either way, on a plain line', () => {
// One drawn arm is a guard clause, not a choice.
const only = buildStepsModel(
payload([anchor, resolve, home], [link(anchor, resolve, { kind: 'store' }), link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON)] })])
);
expect(only.decisions).toEqual([]);
expect([...only.edges.values()].every((e) => e.arm === undefined)).toBe(true);
// A connector with a site that runs under NO condition is not exclusively
// an arm — the step happens either way — so it never claims a side.
const both = buildStepsModel(
payload(
[anchor, resolve, home, welcome],
[
link(anchor, resolve, { kind: 'store' }),
link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON), { file: 'x.ts', line: 9, text: 'push', when: '' }] }),
link(resolve, welcome, { kind: 'navigates', when: `!(${ON})`, sites: [site(`!(${ON})`, true)] }),
]
)
);
expect(both.decisions).toEqual([]);
});
it('words a switch arm by its own value, and the default by else', () => {
expect(armWords({ on: 'status', arm: "status === 'expired'", form: 'switch' })).toBe("'expired'");
expect(armWords({ on: 'status', arm: 'anything', form: 'switch', not: true })).toBe('else');
expect(armWords({ on: 'ready', arm: 'ready', form: 'if' })).toBe('yes');
expect(armWords({ on: 'ready', arm: '!ready', form: 'if', not: true })).toBe('no');
});
});
describe('words per project', () => {
it('names the same box for an app, an API and a web app', () => {
expect(kindWord('screen', 'app')).toBe('screen');