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.
This commit is contained in:
@@ -5,6 +5,7 @@ import * as os from 'os';
|
||||
import { CodeGraph } from '../src';
|
||||
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';
|
||||
import { buildScreens } from '../src/ui-server/api/screens';
|
||||
import { buildSteps } from '../src/ui-server/api/steps';
|
||||
import {
|
||||
expoRouterResolver,
|
||||
routePathForFile,
|
||||
@@ -465,7 +466,12 @@ describe('expo-router: end-to-end', () => {
|
||||
);
|
||||
write(
|
||||
'src/services/post-login.ts',
|
||||
'export const resolvePostLoginRoute = async (): Promise<string> => {\n' +
|
||||
// The literal-union return type is the trap: its routes are string
|
||||
// literals too, BEFORE the ternary — the scan must skip the signature
|
||||
// or the annotation's guardless positions win.
|
||||
'export const resolvePostLoginRoute = async (): Promise<\n' +
|
||||
" '/welcome/' | '/'\n" +
|
||||
'> => {\n' +
|
||||
" return (await seen()) ? '/' : '/welcome/'\n" +
|
||||
'}\n' +
|
||||
'async function seen() { return true }\n' +
|
||||
@@ -531,6 +537,15 @@ describe('expo-router: end-to-end', () => {
|
||||
expect(fromHelper.every((e) => e.provenance === 'heuristic')).toBe(true);
|
||||
expect(fromHelper[0]!.metadata?.synthesizedBy).toBe('expo-router-return');
|
||||
expect(fromHelper[0]!.metadata?.registeredAt).toBe('src/services/login.ts:4');
|
||||
// Each return literal carries its own POSITION: the two arms of
|
||||
// `return (await seen()) ? '/' : '/welcome/'` share a line, and only the
|
||||
// column lets the guard reader say which arm an edge is — without it both
|
||||
// navigations read as `always`.
|
||||
const welcomeEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/welcome')!;
|
||||
const rootEdge = fromHelper.find((e) => routes.find((r) => r.id === e.target)?.name === '/')!;
|
||||
expect(rootEdge.line).toBe(welcomeEdge.line);
|
||||
expect(typeof rootEdge.column).toBe('number');
|
||||
expect(welcomeEdge.column!).toBeGreaterThan(rootEdge.column!);
|
||||
const finishLogin = cg.getNodesByName('finishLogin')[0]!;
|
||||
expect(cg.getOutgoingEdges(finishLogin.id).some((e) => e.target === helper.id && e.kind === 'calls')).toBe(true);
|
||||
const apiPath = cg.getNodesByName('apiPath')[0]!;
|
||||
@@ -555,6 +570,168 @@ describe('expo-router: end-to-end', () => {
|
||||
expect(screens.origins.map((o) => o.node.name)).toEqual(['openItem', 'resolvePostLoginRoute']);
|
||||
expect(screens.dropped).toBe(0);
|
||||
|
||||
// The steps walk reads each arm's own condition off the literal's column:
|
||||
// where the app goes after login is a fork, not two `always`es.
|
||||
const steps = await buildSteps(cg, tmpDir, new URLSearchParams({ symbol: 'finishLogin' }));
|
||||
const stepByLabel = (label: string) => steps.steps.find((s) => s.label === label)!;
|
||||
const toRoot = steps.links.find((l) => l.to === stepByLabel('/').id)!;
|
||||
const toWelcome = steps.links.find((l) => l.to === stepByLabel('/welcome').id)!;
|
||||
expect(toRoot.when).toMatch(/await seen\(\)/);
|
||||
expect(toRoot.when).not.toMatch(/!/);
|
||||
expect(toWelcome.when).toMatch(/!\s*\(?\s*await seen\(\)/);
|
||||
|
||||
cg.close();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// The backward walk must not leave the app's own execution context
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* A navigation written inside a component the graph can only reach BACKWARDS
|
||||
* through the native bridge belongs to the screen whose file it is written in
|
||||
* — not to whichever screen happened to start the round trip.
|
||||
*
|
||||
* The shape, from a real Expo app: `/capture` renders `ARCapturePage`, which
|
||||
* renders `memo(CaptureComponent)`; the `router.push` lives in an inline
|
||||
* listener inside `CaptureComponent`. Nothing points at `CaptureComponent`
|
||||
* except Swift emitters — the walk skips `file` nodes, and `memo(x)` leaves no
|
||||
* edge from the memo to the function — so before the guard the walk escaped
|
||||
* through `rn-event-channel`, came back down into `ReviewScreen` (which had
|
||||
* called the native module), and filed four of `/capture`'s navigations under
|
||||
* `/capture/review`, whose only remaining feed was itself. It also carried the
|
||||
* Swift guards home as conditions on a JavaScript navigation.
|
||||
*/
|
||||
describe('expo-router screens: attribution stops at the native bridge', () => {
|
||||
let tmpDir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
function write(rel: string, content: string) {
|
||||
const full = path.join(tmpDir!, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
it('files the push on the screen whose file holds it, not on the screen that started the round trip', async () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-expo-bridge-'));
|
||||
write(
|
||||
'package.json',
|
||||
JSON.stringify({
|
||||
name: 'app',
|
||||
dependencies: { expo: '52', 'expo-router': '4', react: '18', 'react-native': '0.76' },
|
||||
})
|
||||
);
|
||||
write('src/app/_layout.tsx', 'export default function Layout() { return null }\n');
|
||||
write('src/app/index.tsx', 'export default function Home() { return null }\n');
|
||||
|
||||
// The Swift side: a method the JS calls, which ends in the event emit.
|
||||
write(
|
||||
'ios/CaptureView.swift',
|
||||
`import Foundation
|
||||
@objc(CaptureView)
|
||||
class CaptureView: NSObject {
|
||||
@objc func startRetake() {
|
||||
CaptureEvents.shared.emitCaptureComplete()
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
// The ObjC bridging shim, without which the JS side never reaches Swift.
|
||||
write(
|
||||
'ios/CaptureView.m',
|
||||
`#import <React/RCTBridgeModule.h>
|
||||
@interface RCT_EXTERN_MODULE(CaptureView, NSObject)
|
||||
RCT_EXTERN_METHOD(startRetake)
|
||||
@end
|
||||
`
|
||||
);
|
||||
write(
|
||||
'ios/CaptureEvents.swift',
|
||||
`import Foundation
|
||||
class CaptureEvents: RCTEventEmitter {
|
||||
func emitCaptureComplete() {
|
||||
guard Thread.isMainThread else { return }
|
||||
sendEvent(withName: "onCaptureComplete", body: nil)
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
// /capture — the push is written HERE, in an inline listener inside a
|
||||
// sibling of the route's own default export.
|
||||
write(
|
||||
'src/app/capture/index.tsx',
|
||||
`import { memo, useEffect } from 'react'
|
||||
import { router } from 'expo-router'
|
||||
const MemoizedCaptureComponent = memo(CaptureComponent)
|
||||
export default function ARCapturePage() {
|
||||
return <MemoizedCaptureComponent />
|
||||
}
|
||||
function CaptureComponent() {
|
||||
useEffect(() => {
|
||||
const sub = nativeEmitter.addListener('onCaptureComplete', (data) => {
|
||||
if (!isRetakeBatchActive) {
|
||||
router.push('/capture/review')
|
||||
}
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
// /capture/review — calls into the native module, which is what makes the
|
||||
// Swift emitter backwards-reachable from this screen.
|
||||
write(
|
||||
'src/app/capture/review/index.tsx',
|
||||
`import { NativeModules } from 'react-native'
|
||||
const { CaptureView } = NativeModules
|
||||
export default function ReviewScreen() {
|
||||
function handleRetake() {
|
||||
CaptureView.startRetake()
|
||||
}
|
||||
return handleRetake
|
||||
}
|
||||
`
|
||||
);
|
||||
|
||||
const cg = CodeGraph.initSync(tmpDir);
|
||||
await cg.indexAll();
|
||||
|
||||
// The escape route the walk used to take really is in the graph.
|
||||
const capture = cg.getNodesByName('CaptureComponent').find((n) => n.kind !== 'route')!;
|
||||
const bridged = cg
|
||||
.getIncomingEdgesTo([capture.id], ['calls'])
|
||||
.filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'rn-event-channel');
|
||||
expect(bridged.length).toBeGreaterThan(0);
|
||||
// …and it really is a route back OUT to the other screen: without the
|
||||
// guard the walk runs handleRetake > startRetake > emitCaptureComplete >
|
||||
// CaptureComponent and lands the push on /capture/review.
|
||||
const startRetake = cg.getNodesByName('startRetake').find((n) => n.language === 'swift')!;
|
||||
expect(cg.getIncomingEdgesTo([startRetake.id], ['calls']).map((e) => cg.getNodesByIds([e.source]).get(e.source)?.name)).toContain(
|
||||
'handleRetake'
|
||||
);
|
||||
|
||||
const screens = await buildScreens(cg, tmpDir);
|
||||
const from = (path: string) => screens.screens.find((s) => s.path === path)!;
|
||||
const review = from('/capture/review');
|
||||
const links = screens.links.filter((l) => l.to === review.id);
|
||||
|
||||
// One transition into /capture/review, and it comes from /capture.
|
||||
expect(links.map((l) => screens.screens.find((s) => s.id === l.from)?.path)).toEqual(['/capture']);
|
||||
// Written right there: no chain, and no Swift guard smuggled in.
|
||||
expect(links[0]!.via).toEqual([]);
|
||||
expect(links[0]!.when).toBe('!isRetakeBatchActive');
|
||||
expect(links[0]!.sites[0]!.file).toBe('src/app/capture/index.tsx');
|
||||
// …and /capture/review is not left feeding only itself.
|
||||
expect(screens.links.some((l) => l.from === review.id && l.to === review.id)).toBe(false);
|
||||
|
||||
cg.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,33 @@ beforeAll(async () => {
|
||||
' }\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();
|
||||
});
|
||||
@@ -302,3 +329,53 @@ describe('buildSteps', () => {
|
||||
await expect(buildSteps(cg, tmpDir, q({ symbol: 'nothingNamedThis' }))).rejects.toThrow(/Nothing/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('screen regions', () => {
|
||||
it('a screen names every step’s 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 fold’s 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 code’s 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 dialog’s 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* rule, and the panel's two lists.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildStepsModel, countWords, kindWord, kindWords, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
|
||||
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';
|
||||
|
||||
@@ -150,3 +150,84 @@ describe('row order', () => {
|
||||
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 screen’s 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 Map’s 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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user