Files
codegraph/__tests__/ui-steps-model.test.ts
T
Colby McHenry 882ea143e8 feat(steps): lay out screen pictures by region and render region captions
Adds region-based layout support for screens: steps now carry region information, and the server packs regions into dedicated bands with per-region captions. UI changes introduce RegionCaption and region-aware step rendering; StepsModel and related views (StepsView) consume region data, while the region-aware layout keeps anchor and region boundaries intact. Tests and docs updated to reflect region-driven organization and visualization of screen regions. This enables visualizing a screen’s picture as region-based columns rather than a single distance-driven row.
2026-08-31 15:38:13 -05:00

234 lines
13 KiB
TypeScript
Raw 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 view's model, without a browser: rows by the server's depth, the
* words in a box by kind, one edge per pair with the Screens view's label
* 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 { placeLabels } from '../ui/src/lib/screens-model';
import type { WireNodeRef, WireStep, WireStepLink, 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 };
}
function step(label: string, kind: WireStep['kind'], depth: number, extra: Partial<WireStep> = {}): WireStep {
const node = kind === 'effect' ? null : ref(label, extra.node?.file ?? 'src/a.tsx');
return { id: node?.id ?? `effect:fn:${label}`, kind, anchor: depth === 0, node, label, sub: 'src/a.tsx', depth, cut: null, ...extra };
}
function link(from: WireStep, to: WireStep, extra: Partial<WireStepLink> = {}): WireStepLink {
return { id: `${from.id} ${to.id}`, from: from.id, to: to.id, kind: 'calls', via: [], when: '', label: '', synthesized: false, uncertain: false, sites: [], ...extra };
}
function payload(steps: WireStep[], links: WireStepLink[]): WireStepsPayload {
return {
anchor: steps[0]!.node!,
ambiguous: [],
project: 'app',
steps,
links,
depth: 8,
limit: 120,
through: false,
truncated: { steps: 0, hubs: 0, chrome: 0 },
index: { lastIndexedAt: null, edges: 0, files: 0 },
timing: { elapsedMs: 1 },
};
}
describe('steps model', () => {
const screen = step('/capture/review', 'screen', 0, { screen: { path: '/capture/review', component: ref('ReviewScreen') } });
const handler = step('handleApprove', 'trigger', 1);
const bridge = step('finalizeCaptureSession', 'bridge', 2, { node: ref('finalizeCaptureSession', 'ios/CaptureView.swift', 'swift') });
const event = step('handleZipComplete', 'event', 3, { event: 'onZipComplete' });
const effect = step('client.post', 'effect', 4, { sub: 'network · uploadARCapture', effect: { api: 'client.post', apis: ['client.post'], category: 'network', by: ref('uploadARCapture'), line: 3 } });
const store = step('setZipUri', 'store', 4, { node: ref('setZipUri', 'src/storage/capture.storage.ts') });
const home = step('/', 'screen', 4, { screen: { path: '/', component: null } });
const links = [
link(screen, handler, { kind: 'handler', trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } }),
link(handler, bridge, { kind: 'bridge', when: '!busy' }),
link(bridge, event, { kind: 'event', synthesized: true, via: [ref('emitZipComplete', 'ios/CaptureEvents.swift', 'swift')], when: 'result', label: 'via rn-event-channel · event onZipComplete' }),
link(event, effect, { kind: 'effect', via: [ref('uploadARCapture')] }),
link(event, store, { kind: 'store' }),
link(event, home, { kind: 'navigates', when: 'unlimited' }),
// A second way from the event to the store, unconditional: the pair is one edge saying "2 ways".
{ ...link(event, store, { kind: 'store', when: 'retry' }), id: 'second' },
];
const model = buildStepsModel(payload([screen, handler, bridge, event, effect, store, home], links));
it('puts the anchor on top and each row one step further away', () => {
const y = (id: string) => model.layout.nodes.find((n) => n.id === id)!.y;
expect(y(screen.id)).toBeLessThan(y(handler.id));
expect(y(handler.id)).toBeLessThan(y(bridge.id));
expect(y(bridge.id)).toBeLessThan(y(event.id));
expect(y(event.id)).toBeLessThan(y(effect.id));
expect(y(effect.id)).toBe(y(store.id));
expect(y(effect.id)).toBe(y(home.id));
});
it('one edge per pair, labelled with the innermost condition or a count', () => {
const edges = [...model.edges.values()];
expect(edges).toHaveLength(6);
// A link into a handler says the event, not the conditions.
const toHandler = edges.find((e) => e.to === handler.id)!;
expect(toHandler.label).toBe('onPress · <Button>');
const toBridge = edges.find((e) => e.to === bridge.id)!;
expect(toBridge.label).toBe('NOT busy');
expect(toBridge.kind).toBe('bridge');
const toEvent = edges.find((e) => e.to === event.id)!;
expect(toEvent.synthesized).toBe(true);
expect(toEvent.label).toBe('result');
const toStore = edges.find((e) => e.to === store.id)!;
expect(toStore.links).toHaveLength(2);
expect(toStore.label).toBe('2 ways · 1 conditional');
expect(toStore.kind).toBe('store');
});
it('counts steps per kind', () => {
expect(model.counts).toEqual({ anchor: 0, screen: 2, trigger: 1, bridge: 1, event: 1, store: 1, effect: 1 });
});
it('words a box by its kind', () => {
expect(stepLabel(bridge)).toBe('⇢ finalizeCaptureSession');
expect(stepLabel(event)).toBe('⇠ onZipComplete');
expect(stepLabel({ ...event, events: ['onZipComplete', 'onZipError', 'onCameraReady'] })).toBe('⇠ onZipComplete +2');
expect(stepLabel(screen)).toBe('/capture/review');
expect(stepSub(event)).toBe('handleZipComplete · a.tsx');
expect(stepSub(bridge)).toBe('native · CaptureView.swift');
expect(stepSub(store)).toBe('store · capture.storage.ts');
expect(stepSub(effect)).toBe('network · uploadARCapture');
expect(kindWord('effect')).toBe('outside the index');
expect(triggerWords({ kind: 'option', name: 'onSubmit', of: 'useFormik', in: 'LoginButton' })).toBe('onSubmit · useFormik(…)');
expect(triggerWords({ kind: 'callback', name: 'addListener', of: "'onZipComplete'", in: 'X' })).toBe("addListener('onZipComplete')");
expect(triggerWords({ kind: 'callback', name: 'useEffect', of: null, in: 'X' })).toBe('useEffect');
expect(stepSub({ ...handler, trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } })).toBe('onPress · <Button> · a.tsx');
expect(stepViaText(links[2]!)).toBe('emitZipComplete');
});
it('labels a selected step at the far end of each line, and lists its links', () => {
const pills = placeLabels(model, event.id);
expect(pills.hidden).toBe(0);
const words = [...pills.pills.values()].map((p) => p.text).sort();
expect(words).toEqual(['← result', '→ 2 ways · 1 conditional', '→ unlimited']);
const lists = stepNeighbourhood(payload([screen, handler, bridge, event, effect, store, home], links), event.id);
expect(lists.arrivesFrom.map((l) => l.from)).toEqual([bridge.id]);
expect(lists.leadsTo.map((l) => l.to)).toEqual([effect.id, store.id, home.id, store.id]);
});
});
describe('words per project', () => {
it('names the same box for an app, an API and a web app', () => {
expect(kindWord('screen', 'app')).toBe('screen');
expect(kindWord('screen', 'api')).toBe('endpoint');
expect(kindWord('screen', 'web')).toBe('page');
// A route that leads with a verb is an endpoint wherever it is.
const endpoint = { id: 'r', kind: 'screen', anchor: false, node: null, label: 'POST /users', sub: 'createUser', depth: 1, cut: null, screen: { path: 'POST /users', component: null, endpoint: true, inline: false } } as const;
expect(kindWord('screen', 'web', endpoint)).toBe('endpoint');
expect(kindWords('store', 'api')).toEqual(['data call', 'data calls']);
expect(kindWords('bridge', 'app')).toEqual(['native call', 'native calls']);
expect(countWords(11, 'effect', 'api')).toBe('11 outside the index');
expect(countWords(1, 'trigger')).toBe('1 handler');
expect(countWords(3, 'trigger')).toBe('3 handlers');
});
it('says what fires a server-side step', () => {
expect(triggerWords({ kind: 'request', name: 'POST', of: '/users', in: 'users.routes.ts', after: ['authenticate', 'validate(…)'] })).toBe('POST /users · after authenticate, validate(…)');
expect(triggerWords({ kind: 'decorator', name: 'Process', of: "'email'", in: 'x.ts' })).toBe("@Process('email')");
expect(triggerWords({ kind: 'load', name: 'GET', of: '/blog/[slug]', in: 'page.tsx' })).toBe('page load · /blog/[slug]');
});
});
describe('row order', () => {
it('lays a row out in the order the server gave, not by id', () => {
const anchor = step('/login', 'screen', 0, { anchor: true });
const a = step('User.findOne', 'effect', 1, { order: 0 });
const b = step('jwt.sign', 'effect', 1, { order: 1 });
const c = step('200', 'effect', 1, { order: 2 });
const d = step('401', 'effect', 1, { order: 3 });
const model = buildStepsModel(payload([anchor, d, c, b, a], [link(anchor, a), link(anchor, b), link(anchor, c), link(anchor, d)]));
const row = model.layout.nodes.filter((n) => n.id !== anchor.id).sort((x, y) => x.x - y.x).map((n) => n.id);
expect(row).toEqual([a.id, b.id, c.id, d.id]);
});
});
describe('a screen laid out by region', () => {
const A = { id: 'component:PanelA', label: 'PanelA' };
const B = { id: 'component:PanelB', label: 'PanelB' };
const anchor = step('/', 'screen', 0, { anchor: true });
const a1 = step('tapSave', 'trigger', 1, { order: 0, region: A });
const a2 = step('tapUndo', 'trigger', 1, { order: 1, region: A });
const a3 = step('saveThing', 'store', 2, { order: 0, region: A, node: ref('saveThing', 'src/things.storage.ts') });
const b1 = step('tapShare', 'trigger', 1, { order: 2, region: B, node: ref('tapShare', 'src/b.tsx') });
const links = [
link(anchor, a1),
link(anchor, a2),
link(anchor, b1),
link(a1, a3, { kind: 'store' }),
link(a1, b1),
// Another region's way into a shared store — a lead-to line like any other.
link(b1, a3, { kind: 'store' }),
];
const model = buildStepsModel(payload([anchor, a1, a2, b1, a3], links));
const at = (id: string) => model.layout.nodes.find((n) => n.id === id)!;
const between = (id: string, zone: { x: number; width: number }) => {
const n = at(id);
return n.x >= zone.x && n.x + n.width <= zone.x + zone.width;
};
const edge = (from: string, to: string) => model.layout.edges.find((e) => e.source === from && e.target === to)!;
it('names the regions in the order the walk met them, each holding its own boxes', () => {
expect(model.regions!.map((z) => z.label)).toEqual(['PanelA', 'PanelB']);
const [zoneA, zoneB] = model.regions!;
expect(between(a1.id, zoneA!)).toBe(true);
expect(between(a2.id, zoneA!)).toBe(true);
expect(between(a3.id, zoneA!)).toBe(true);
expect(between(b1.id, zoneB!)).toBe(true);
// Side by side, not overlapping: the second region starts past the first.
expect(zoneB!.x).toBeGreaterThanOrEqual(zoneA!.x + zoneA!.width);
});
it('keeps a step above what it sets in motion, inside its region', () => {
expect(at(anchor.id).y).toBeLessThan(at(a1.id).y);
expect(at(a1.id).y).toBe(at(a2.id).y);
expect(at(a3.id).y).toBeGreaterThan(at(a1.id).y);
});
it('at rest hides only the screens own fan and what points back up; every other lead-to draws', () => {
expect(model.regionEntries).toEqual(new Set([a1.id, b1.id]));
// One line from the screen into each region stands in for its whole fan.
expect(stepEdgeVisible(model, edge(anchor.id, a1.id), null)).toBe(true);
expect(stepEdgeVisible(model, edge(anchor.id, a2.id), null)).toBe(false);
expect(stepEdgeVisible(model, edge(anchor.id, b1.id), null)).toBe(true);
// A region's internal line, and another region's way into a shared step.
expect(stepEdgeVisible(model, edge(a1.id, a3.id), null)).toBe(true);
expect(stepEdgeVisible(model, edge(b1.id, a3.id), null)).toBe(true);
// Two boxes on one row point sideways — back-ish, a click away as everywhere.
expect(stepEdgeVisible(model, edge(a1.id, b1.id), null)).toBe(false);
// Selecting a step brings out everything that touches it, and only that.
expect(stepEdgeVisible(model, edge(a1.id, b1.id), a1.id)).toBe(true);
expect(stepEdgeVisible(model, edge(anchor.id, a2.id), a1.id)).toBe(false);
});
it('stacks a handler above the store it calls, even when both are one hop from the screen', () => {
// Anchor distance is flat inside a region: both of these are depth 1, and
// side by side their link was a level arch, hidden at rest — the store
// floated. The region's own links order its rows instead.
const C = { id: 'component:PanelC', label: 'PanelC' };
const root = step('/', 'screen', 0, { anchor: true });
const h = step('tapCopy', 'trigger', 1, { order: 0, region: C });
const s = step('copyThing', 'store', 1, { order: 1, region: C, node: ref('copyThing', 'src/c.storage.ts') });
const m = buildStepsModel(payload([root, h, s], [link(root, h), link(root, s), link(h, s, { kind: 'store' })]));
const y = (id: string) => m.layout.nodes.find((n) => n.id === id)!.y;
expect(y(s.id)).toBeGreaterThan(y(h.id));
const e = m.layout.edges.find((x) => x.source === h.id && x.target === s.id)!;
expect(e.route).toBe('down');
expect(stepEdgeVisible(m, e, null)).toBe(true);
});
it('a payload without regions keeps the rows, and the Maps at-rest rule', () => {
const plain = buildStepsModel(payload([step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1)], [link(step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1))]));
expect(plain.regions).toBeNull();
expect(plain.regionEntries).toBeNull();
});
});