Files
codegraph/__tests__/ui-steps-api.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

382 lines
19 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.
/**
* `GET /api/steps` — what happens from a screen, as typed steps.
*
* Against a real index of a small Expo + React Native app, shaped to cross
* every boundary the endpoint classifies: a screen whose handler (a
* `useCallback`) calls a Swift method through an `RCT_EXTERN_MODULE` shim,
* the Swift side sending an event the screen listens to, the listener calling
* an API function that leaves the index (`client.post`), a store action in a
* store file, and a navigation to a second screen behind a condition. The
* pure layout is tested without an index in `ui-steps-model.test.ts`.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
import { buildSteps, crossing, effectCategory, isStoreFile } from '../src/ui-server/api/steps';
let tmpDir: string;
let cg: CodeGraph;
function write(rel: string, content: string): void {
const full = path.join(tmpDir, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
beforeAll(async () => {
await initGrammars();
await loadAllGrammars();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ui-steps-'));
write('package.json', JSON.stringify({ name: 'app', dependencies: { expo: '52', 'expo-router': '4', 'react-native': '0.76' } }));
write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
write('src/app/index.tsx', "import { router } from 'expo-router'\nexport default function Home() {\n return null\n}\n");
write(
'src/components/capture/capture-view.tsx',
"import { NativeModules, NativeEventEmitter } from 'react-native'\n" +
'export const captureView = NativeModules.CaptureView\n' +
'export const nativeEmitter = new NativeEventEmitter(NativeModules.CaptureEvents)\n'
);
write('src/api/client.ts', "import axios from 'axios'\nexport const client = axios.create({ baseURL: 'x' })\n");
write(
'src/api/frames.ts',
"import { client } from './client'\n" +
'export async function uploadARCapture(uri: string) {\n' +
" await client.post('/frames', { uri })\n" +
" return client.get('/frames/status')\n" +
'}\n'
);
write(
'src/storage/capture.storage.ts',
"import { create } from 'zustand'\n" +
'const useCaptureStorage = create<State>((set) => ({\n' +
' zipUri: null,\n' +
' setZipUri: (zipUri: string) => set({ zipUri }),\n' +
'}))\n' +
'export default useCaptureStorage\n'
);
write(
'src/app/capture/review.tsx',
"import { useCallback, useEffect } from 'react'\n" +
"import { router } from 'expo-router'\n" +
"import { captureView, nativeEmitter } from '../../components/capture/capture-view'\n" +
"import { uploadARCapture } from '../../api/frames'\n" +
"import useCaptureStorage from '../../storage/capture.storage'\n" +
'export default function ReviewScreen({ unlimited }: { unlimited: boolean }) {\n' +
' const setZipUri = useCaptureStorage((s) => s.setZipUri)\n' +
' const handleApprove = useCallback(() => {\n' +
' captureView.finalizeCaptureSession()\n' +
' }, [])\n' +
' const handleZipComplete = useCallback(async (data: { uri: string }) => {\n' +
' setZipUri(data.uri)\n' +
' await uploadARCapture(data.uri)\n' +
" Alert.alert('Uploaded', data.uri, [{ text: 'OK' }])\n" +
" if (unlimited) router.replace('/')\n" +
' }, [unlimited])\n' +
' useEffect(() => {\n' +
" const sub = nativeEmitter.addListener('onZipComplete', handleZipComplete)\n" +
' return () => sub.remove()\n' +
' }, [handleZipComplete])\n' +
' const form = useForm({ onSubmit: () => handleSubmit() })\n' +
' function handleSubmit() {\n' +
' captureView.finalizeCaptureSession()\n' +
' }\n' +
' return <Button onPress={handleApprove} />\n' +
'}\n'
);
write(
'src/app/capture/index.tsx',
"import { memo, useCallback } from 'react'\n" +
"import { captureView } from '../../components/capture/capture-view'\n" +
'function CaptureComponent() {\n' +
' const handleOpen = useCallback(() => {\n' +
' captureView.finalizeCaptureSession()\n' +
' }, [])\n' +
' return <Button onPress={() => handleOpen()} />\n' +
'}\n' +
'const MemoizedCaptureComponent = memo(CaptureComponent)\n' +
'export default function CapturePage() {\n' +
' return <MemoizedCaptureComponent />\n' +
'}\n'
);
write(
'ios/CaptureView.m',
'#import <React/RCTViewManager.h>\n@interface RCT_EXTERN_MODULE(CaptureView, RCTViewManager)\nRCT_EXTERN_METHOD(finalizeCaptureSession)\n@end\n'
);
write(
'ios/CaptureView.swift',
'import Foundation\n' +
'class CaptureView: RCTViewManager {\n' +
' @objc func finalizeCaptureSession() {\n' +
' let result = zip()\n' +
' if result {\n' +
' CaptureEvents.shared.emitZipComplete()\n' +
' }\n' +
' }\n' +
' func zip() -> Bool { return true }\n' +
'}\n'
);
write(
'ios/CaptureEvents.swift',
'import Foundation\n' +
'class CaptureEvents: RCTEventEmitter {\n' +
' static let shared = CaptureEvents()\n' +
' func emitZipComplete() {\n' +
' sendEvent(withName: "onZipComplete", body: nil)\n' +
' }\n' +
'}\n'
);
write(
'src/api/remove-thing.ts',
"import { client } from './client'\n" +
'export async function removeThing(name: string) {\n' +
" await client.post('/things/remove', { name })\n" +
'}\n'
);
// The dialog-confirm-then-act pattern: the prompt is an effect box AND the
// thing that fires the handler bound in its buttons.
write(
'src/app/confirm.tsx',
"import { Alert, Button } from 'react-native'\n" +
"import { removeThing } from '../api/remove-thing'\n" +
'export default function ConfirmScreen() {\n' +
' return (\n' +
' <Button\n' +
' title="remove"\n' +
' onPress={() =>\n' +
" Alert.prompt('Remove thing', 'Which one?', [\n" +
" { text: 'Cancel' },\n" +
" { text: 'OK', onPress: (name) => { if (name) removeThing(name) } },\n" +
' ])\n' +
' }\n' +
' />\n' +
' )\n' +
'}\n'
);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
});
afterAll(() => {
cg?.close();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});
const q = (params: Record<string, string>) => new URLSearchParams(params);
describe('classification helpers', () => {
it('crossing: JS → native is a bridge, native → JS an event, anything else nothing', () => {
expect(crossing('tsx', 'swift')).toBe('bridge');
expect(crossing('swift', 'tsx')).toBe('event');
expect(crossing('typescript', 'javascript')).toBeNull();
expect(crossing('swift', 'objc')).toBeNull();
});
it('store files', () => {
expect(isStoreFile('src/storage/capture.storage.ts')).toBe(true);
expect(isStoreFile('src/stores/user.ts')).toBe(true);
expect(isStoreFile('src/features/cart/cart.slice.ts')).toBe(true);
expect(isStoreFile('src/components/button.tsx')).toBe(false);
expect(isStoreFile('src/restore/thing.ts')).toBe(false);
});
it('effects: a curated table, by reference text', () => {
expect(effectCategory('client.post')).toBe('network');
expect(effectCategory('fetch')).toBe('network');
expect(effectCategory('AsyncStorage.setItem')).toBe('storage');
expect(effectCategory('Linking.openURL')).toBe('device');
expect(effectCategory('DdRum.addAction')).toBe('telemetry');
expect(effectCategory('Math.max')).toBeNull();
expect(effectCategory('i18n.t')).toBeNull();
});
});
describe('buildSteps', () => {
it('walks a screen through its handler, the bridge, the event, the store and the request', async () => {
const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
expect(review).toBeDefined();
const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id }));
const byLabel = new Map(payload.steps.map((s) => [s.label, s]));
const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
expect(kinds['/capture/review']).toBe('screen');
expect(payload.steps.find((s) => s.anchor)?.label).toBe('/capture/review');
// The handler is wired to the tap, so it is a trigger; the call it makes
// crosses into Swift, so that is a bridge; the Swift side's event lands
// on the named listener; the listener writes the store, leaves the index
// through `client.post`, and navigates home behind `unlimited`.
expect(kinds['handleApprove']).toBe('trigger');
expect(kinds['finalizeCaptureSession']).toBe('bridge');
expect(kinds['handleZipComplete']).toBe('event');
expect(byLabel.get('handleZipComplete')?.event).toBe('onZipComplete');
expect(byLabel.get('handleZipComplete')?.events).toEqual(['onZipComplete']);
expect(kinds['setZipUri']).toBe('store');
// One box per (function, category): both calls the upload makes into the
// network, labelled by the first and counting the rest.
const network = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!;
expect(network.label).toBe('client.post +1');
expect(network.effect?.apis).toEqual(['client.post', 'client.get']);
expect(network.effect?.by.name).toBe('uploadARCapture');
expect(kinds['/']).toBe('screen');
// Another screen is a boundary: drawn, marked, not entered.
expect(byLabel.get('/')?.cut).toBe('screen');
const link = (from: string, to: string) =>
payload.links.find((l) => l.from === byLabel.get(from)!.id && l.to === byLabel.get(to)!.id);
const req = link('handleZipComplete', 'client.post +1');
const tap = link('/capture/review', 'handleApprove');
expect(tap?.kind).toBe('handler');
// What fires it — read at the site: the JSX prop and its element, and the
// function that writes the binding.
expect(tap?.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' });
expect(byLabel.get('handleApprove')?.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' });
// A function called from under an `on*` option is a handler too — the
// Formik shape — and the option names what fires it.
expect(kinds['handleSubmit']).toBe('trigger');
expect(link('/capture/review', 'handleSubmit')?.trigger).toEqual({ kind: 'option', name: 'onSubmit', of: 'useForm', in: 'ReviewScreen' });
// The listener registration is a callback binding on the handler link.
expect(link('/capture/review', 'handleZipComplete')?.trigger).toEqual({ kind: 'callback', name: 'addListener', of: "'onZipComplete'", in: 'ReviewScreen' });
expect(link('handleApprove', 'finalizeCaptureSession')?.kind).toBe('bridge');
const evt = link('finalizeCaptureSession', 'handleZipComplete');
expect(evt?.kind).toBe('event');
expect(evt?.synthesized).toBe(true);
expect(evt?.via.map((v) => v.name)).toEqual(['emitZipComplete']);
expect(evt?.when).toBe('result');
expect(evt?.label).toContain('event onZipComplete');
const storeLink = link('handleZipComplete', 'setZipUri');
expect(storeLink?.kind).toBe('store');
// Every call-shaped site says what it passes.
expect(storeLink?.sites[0]?.args).toBe('data.uri');
expect(link('handleApprove', 'finalizeCaptureSession')?.sites[0]?.args).toBe('');
// One call behind an effect box: the box says it. Several: the panel does.
const alert = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'device')!;
expect(alert.label).toBe("Alert.alert('Uploaded', data.uri, […])");
expect(network.label).toBe('client.post +1');
expect(req?.sites.map((s) => `${s.text}(${s.args})`)).toEqual(["client.post('/frames', { uri })", "client.get('/frames/status')"]);
expect(req?.kind).toBe('effect');
expect(req?.via.map((v) => v.name)).toEqual(['uploadARCapture']);
const nav = link('handleZipComplete', '/');
expect(nav?.kind).toBe('navigates');
expect(nav?.when).toBe('unlimited');
expect(nav?.sites[0]?.text).toBe('replace /');
// Every site carries the whole condition it runs under — one scenario each.
expect(nav?.sites[0]?.when).toBe('unlimited');
expect(evt?.sites[0]?.when).toBe('result');
expect(storeLink?.sites[0]?.when).toBe('');
// Rows: the anchor on 0, then one more step away each. The listener is
// registered BY the screen (`addListener('onZipComplete', handleZipComplete)`),
// so it sits one step from the anchor as a handler and the native event
// arrives at it from further down — a link back up the picture — and
// names the event on the box.
expect(byLabel.get('/capture/review')?.depth).toBe(0);
expect(byLabel.get('handleApprove')?.depth).toBe(1);
expect(byLabel.get('finalizeCaptureSession')?.depth).toBe(2);
expect(byLabel.get('handleZipComplete')?.depth).toBe(1);
expect(link('/capture/review', 'handleZipComplete')?.kind).toBe('handler');
expect(network.depth).toBe(2);
expect(payload.through).toBe(false);
expect(payload.truncated).toEqual({ steps: 0, hubs: 0, chrome: 0 });
// No cap fired; the only thing not entered is the other screen.
expect(payload.steps.filter((s) => s.cut !== null).map((s) => [s.label, s.cut])).toEqual([['/', 'screen']]);
});
it('walks through a memo-wrapped component into the screen body', async () => {
const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!;
const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
const kinds = Object.fromEntries(payload.steps.map((s) => [s.label, s.kind]));
// The wrapper and the component are render hops, folded into the link;
// the handler — called from an inline arrow under `onPress` — is the
// first box, the native call the next.
expect(kinds['handleOpen']).toBe('trigger');
expect(kinds['finalizeCaptureSession']).toBe('bridge');
const toHandler = payload.links.find((l) => l.to === payload.steps.find((s) => s.label === 'handleOpen')!.id)!;
expect(toHandler.via.map((v) => v.name)).toEqual(['MemoizedCaptureComponent', 'CaptureComponent']);
expect(toHandler.trigger).toEqual({ kind: 'prop', name: 'onPress', of: 'Button', in: 'CaptureComponent' });
expect(payload.steps.map((s) => s.label)).not.toContain('CaptureComponent');
});
it('enters other screens when asked to continue through them', async () => {
const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, through: '1' }));
expect(payload.through).toBe(true);
expect(payload.steps.find((s) => s.label === '/')?.cut).toBeNull();
});
it('anchors by name, prefers the screen, and lists the rest as ambiguous', async () => {
const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' }));
expect(payload.anchor.name).toBe('handleApprove');
expect(payload.steps[0]?.kind).toBe('anchor');
expect(payload.steps.map((s) => s.label)).toContain('finalizeCaptureSession');
});
it('a depth cap is announced on the step it stopped at', async () => {
const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id, depth: '2' }));
// The bridge is two steps out: drawn, not explored — and says so.
const bridge = payload.steps.find((s) => s.label === 'finalizeCaptureSession')!;
expect(bridge.cut).toBe('depth');
expect(payload.links.some((l) => l.kind === 'event')).toBe(false);
// The listener still sits one step out, so the event step keeps its
// handler kind: nothing arrived at it from native within the cap.
expect(payload.steps.find((s) => s.label === 'handleZipComplete')?.kind).toBe('trigger');
});
it('refuses a missing anchor and an unknown id', async () => {
await expect(buildSteps(cg, tmpDir, q({}))).rejects.toThrow(/anchor/);
await expect(buildSteps(cg, tmpDir, q({ anchor: 'function:nope' }))).rejects.toThrow(/No symbol/);
await expect(buildSteps(cg, tmpDir, q({ symbol: 'nothingNamedThis' }))).rejects.toThrow(/Nothing/);
});
});
describe('screen regions', () => {
it('a screen names every steps region: the screen body for its own code, inherited down the walk', async () => {
const review = cg.getNodesByKind('route').find((r) => r.name === '/capture/review')!;
const payload = await buildSteps(cg, tmpDir, q({ anchor: review.id }));
const byLabel = Object.fromEntries(payload.steps.map((s) => [s.label, s]));
// Every step of a screen's picture belongs somewhere.
for (const s of payload.steps) if (!s.anchor) expect(s.region, s.label).toBeDefined();
// A handler declared in the screen body belongs to the screen's own component…
expect(byLabel['handleApprove']!.region!.label).toBe('ReviewScreen');
// …and what it reaches inherits the region that got there first.
expect(byLabel['finalizeCaptureSession']!.region!.id).toBe(byLabel['handleApprove']!.region!.id);
expect(byLabel['setZipUri']!.region!.label).toBe('ReviewScreen');
});
it('a step reached through a folded component belongs to that component — the folds first node', async () => {
const capture = cg.getNodesByKind('route').find((r) => r.name === '/capture')!;
const payload = await buildSteps(cg, tmpDir, q({ anchor: capture.id }));
const handler = payload.steps.find((s) => s.label === 'handleOpen')!;
const toHandler = payload.links.find((l) => l.to === handler.id)!;
expect(handler.region!.label).toBe(toHandler.via[0]!.name);
});
it('an anchor with a body carries no regions — its rows read in the codes order', async () => {
const payload = await buildSteps(cg, tmpDir, q({ symbol: 'handleApprove' }));
for (const s of payload.steps) expect(s.region).toBeUndefined();
});
});
describe('fired from a dialog', () => {
it('a handler bound inside a dialogs buttons arrives from the dialog, not from the screen', async () => {
const confirm = cg.getNodesByKind('route').find((r) => r.name === '/confirm')!;
const payload = await buildSteps(cg, tmpDir, q({ anchor: confirm.id }));
const prompt = payload.steps.find((s) => s.kind === 'effect' && s.label.startsWith('Alert.prompt'))!;
const handler = payload.steps.find((s) => s.label === 'removeThing')!;
const into = payload.links.filter((l) => l.to === handler.id);
expect(into).toHaveLength(1);
expect(into[0]!.from).toBe(prompt.id);
expect(into[0]!.trigger?.of).toBe('Alert.prompt');
// A handler CALLED from under a binding says what it passes, as every
// call-shaped site does — the argument is what a wrapper wraps.
expect(into[0]!.sites[0]!.args).toBe('name');
// One step deeper than the prompt that fires it, in the prompt's region.
expect(handler.depth).toBe(prompt.depth + 1);
expect(handler.region!.id).toBe(prompt.region!.id);
// …and what the handler does hangs on below.
const post = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'network')!;
expect(payload.links.some((l) => l.from === handler.id && l.to === post.id)).toBe(true);
});
});