ui-steps-model.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. /**
  2. * The Steps view's model, without a browser: rows by the server's depth, the
  3. * words in a box by kind, one edge per pair with the Screens view's label
  4. * rule, and the panel's two lists.
  5. */
  6. import { describe, it, expect } from 'vitest';
  7. import { armWords, buildStepsModel, countWords, kindWord, kindWords, stepEdgeVisible, stepLabel, stepNeighbourhood, stepSub, stepViaText, triggerWords } from '../ui/src/lib/steps-model';
  8. import { placeLabels } from '../ui/src/lib/screens-model';
  9. import type { WireNodeRef, WireStep, WireStepLink, WireStepSite, WireStepsPayload } from '../ui/src/lib/wire';
  10. function ref(name: string, file = 'src/a.tsx', language: WireNodeRef['language'] = 'tsx'): WireNodeRef {
  11. return { id: `function:${name}`, kind: 'function', name, qualifiedName: name, file, line: 1, endLine: 9, language, test: false };
  12. }
  13. function step(label: string, kind: WireStep['kind'], depth: number, extra: Partial<WireStep> = {}): WireStep {
  14. const node = kind === 'effect' ? null : ref(label, extra.node?.file ?? 'src/a.tsx');
  15. return { id: node?.id ?? `effect:fn:${label}`, kind, anchor: depth === 0, node, label, sub: 'src/a.tsx', depth, cut: null, ...extra };
  16. }
  17. function link(from: WireStep, to: WireStep, extra: Partial<WireStepLink> = {}): WireStepLink {
  18. return { id: `${from.id} ${to.id}`, from: from.id, to: to.id, kind: 'calls', via: [], when: '', label: '', synthesized: false, uncertain: false, sites: [], ...extra };
  19. }
  20. function payload(steps: WireStep[], links: WireStepLink[]): WireStepsPayload {
  21. return {
  22. anchor: steps[0]!.node!,
  23. ambiguous: [],
  24. project: 'app',
  25. steps,
  26. links,
  27. depth: 8,
  28. limit: 120,
  29. through: false,
  30. truncated: { steps: 0, hubs: 0, chrome: 0 },
  31. index: { lastIndexedAt: null, edges: 0, files: 0 },
  32. timing: { elapsedMs: 1 },
  33. };
  34. }
  35. describe('steps model', () => {
  36. const screen = step('/capture/review', 'screen', 0, { screen: { path: '/capture/review', component: ref('ReviewScreen') } });
  37. const handler = step('handleApprove', 'trigger', 1);
  38. const bridge = step('finalizeCaptureSession', 'bridge', 2, { node: ref('finalizeCaptureSession', 'ios/CaptureView.swift', 'swift') });
  39. const event = step('handleZipComplete', 'event', 3, { event: 'onZipComplete' });
  40. const effect = step('client.post', 'effect', 4, { sub: 'network · uploadARCapture', effect: { api: 'client.post', apis: ['client.post'], category: 'network', by: ref('uploadARCapture'), line: 3 } });
  41. const store = step('setZipUri', 'store', 4, { node: ref('setZipUri', 'src/storage/capture.storage.ts') });
  42. const home = step('/', 'screen', 4, { screen: { path: '/', component: null } });
  43. const links = [
  44. link(screen, handler, { kind: 'handler', trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } }),
  45. link(handler, bridge, { kind: 'bridge', when: '!busy' }),
  46. link(bridge, event, { kind: 'event', synthesized: true, via: [ref('emitZipComplete', 'ios/CaptureEvents.swift', 'swift')], when: 'result', label: 'via rn-event-channel · event onZipComplete' }),
  47. link(event, effect, { kind: 'effect', via: [ref('uploadARCapture')] }),
  48. link(event, store, { kind: 'store' }),
  49. link(event, home, { kind: 'navigates', when: 'unlimited' }),
  50. // A second way from the event to the store, unconditional: the pair is one edge saying "2 ways".
  51. { ...link(event, store, { kind: 'store', when: 'retry' }), id: 'second' },
  52. ];
  53. const model = buildStepsModel(payload([screen, handler, bridge, event, effect, store, home], links));
  54. it('puts the anchor on top and each row one step further away', () => {
  55. const y = (id: string) => model.layout.nodes.find((n) => n.id === id)!.y;
  56. expect(y(screen.id)).toBeLessThan(y(handler.id));
  57. expect(y(handler.id)).toBeLessThan(y(bridge.id));
  58. expect(y(bridge.id)).toBeLessThan(y(event.id));
  59. expect(y(event.id)).toBeLessThan(y(effect.id));
  60. expect(y(effect.id)).toBe(y(store.id));
  61. expect(y(effect.id)).toBe(y(home.id));
  62. });
  63. it('one edge per pair, labelled with the innermost condition or a count', () => {
  64. const edges = [...model.edges.values()];
  65. expect(edges).toHaveLength(6);
  66. // A link into a handler says the event, not the conditions.
  67. const toHandler = edges.find((e) => e.to === handler.id)!;
  68. expect(toHandler.label).toBe('onPress · <Button>');
  69. const toBridge = edges.find((e) => e.to === bridge.id)!;
  70. expect(toBridge.label).toBe('NOT busy');
  71. expect(toBridge.kind).toBe('bridge');
  72. const toEvent = edges.find((e) => e.to === event.id)!;
  73. expect(toEvent.synthesized).toBe(true);
  74. expect(toEvent.label).toBe('result');
  75. const toStore = edges.find((e) => e.to === store.id)!;
  76. expect(toStore.links).toHaveLength(2);
  77. expect(toStore.label).toBe('2 ways · 1 conditional');
  78. expect(toStore.kind).toBe('store');
  79. });
  80. it('counts steps per kind', () => {
  81. expect(model.counts).toEqual({ anchor: 0, screen: 2, trigger: 1, bridge: 1, event: 1, store: 1, effect: 1 });
  82. });
  83. it('words a box by its kind', () => {
  84. expect(stepLabel(bridge)).toBe('⇢ finalizeCaptureSession');
  85. expect(stepLabel(event)).toBe('⇠ onZipComplete');
  86. expect(stepLabel({ ...event, events: ['onZipComplete', 'onZipError', 'onCameraReady'] })).toBe('⇠ onZipComplete +2');
  87. expect(stepLabel(screen)).toBe('/capture/review');
  88. expect(stepSub(event)).toBe('handleZipComplete · a.tsx');
  89. expect(stepSub(bridge)).toBe('native · CaptureView.swift');
  90. expect(stepSub(store)).toBe('store · capture.storage.ts');
  91. expect(stepSub(effect)).toBe('network · uploadARCapture');
  92. expect(kindWord('effect')).toBe('outside the index');
  93. expect(triggerWords({ kind: 'option', name: 'onSubmit', of: 'useFormik', in: 'LoginButton' })).toBe('onSubmit · useFormik(…)');
  94. expect(triggerWords({ kind: 'callback', name: 'addListener', of: "'onZipComplete'", in: 'X' })).toBe("addListener('onZipComplete')");
  95. expect(triggerWords({ kind: 'callback', name: 'useEffect', of: null, in: 'X' })).toBe('useEffect');
  96. expect(stepSub({ ...handler, trigger: { kind: 'prop', name: 'onPress', of: 'Button', in: 'ReviewScreen' } })).toBe('onPress · <Button> · a.tsx');
  97. expect(stepViaText(links[2]!)).toBe('emitZipComplete');
  98. });
  99. it('labels a selected step at the far end of each line, and lists its links', () => {
  100. const pills = placeLabels(model, event.id);
  101. expect(pills.hidden).toBe(0);
  102. const words = [...pills.pills.values()].map((p) => p.text).sort();
  103. expect(words).toEqual(['← result', '→ 2 ways · 1 conditional', '→ unlimited']);
  104. const lists = stepNeighbourhood(payload([screen, handler, bridge, event, effect, store, home], links), event.id);
  105. expect(lists.arrivesFrom.map((l) => l.from)).toEqual([bridge.id]);
  106. expect(lists.leadsTo.map((l) => l.to)).toEqual([effect.id, store.id, home.id, store.id]);
  107. });
  108. });
  109. describe('a decision drawn where it is made', () => {
  110. // The real shape this exists for: `return (await hasSeenWelcome(id)) ?
  111. // '/home/' : '/welcome/'` inside a store action, whose two returned routes
  112. // are two `navigates` edges out of ONE box. Each carried the whole
  113. // predicate — one of them the other's negation — and at rest the tree drew
  114. // both with no label at all, so nothing said it was a choice.
  115. const ON = 'await hasSeenWelcome(welcomeUserId())';
  116. const BRANCH = '140:9';
  117. const site = (when: string, not?: true): WireStepSite => ({
  118. file: 'src/org-user.storage.ts',
  119. line: 140,
  120. text: `push ${when}`,
  121. when,
  122. decision: { branch: BRANCH, on: ON, arm: when, form: 'ternary', ...(not ? { not: true as const } : {}) },
  123. });
  124. const anchor = step('/terms-of-service', 'screen', 0, { anchor: true });
  125. const resolve = step('resolvePostLoginRoute', 'store', 1, { node: ref('resolvePostLoginRoute', 'src/org-user.storage.ts') });
  126. const home = step('/home', 'screen', 2, { screen: { path: '/home', component: null } });
  127. const welcome = step('/welcome', 'screen', 2, { screen: { path: '/welcome', component: null } });
  128. const links = [
  129. link(anchor, resolve, { kind: 'store' }),
  130. link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON)] }),
  131. link(resolve, welcome, { kind: 'navigates', when: `!(${ON})`, sites: [site(`!(${ON})`, true)] }),
  132. ];
  133. const model = buildStepsModel(payload([anchor, resolve, home, welcome], links));
  134. const edgeTo = (id: string) => [...model.edges.values()].find((e) => e.to === id)!;
  135. it('says the condition once, under the box that decides it', () => {
  136. expect(model.decisions).toHaveLength(1);
  137. const d = model.decisions[0]!;
  138. expect(d.label).toBe('await hasSeenWelcome(welcomeUserId())?');
  139. // Under the deciding box and centred on it — not under the arms. The
  140. // condition may take more room than the box, since reading it is the
  141. // whole point of the caption.
  142. const box = model.layout.nodes.find((n) => n.id === resolve.id)!;
  143. expect(d.x + d.width / 2).toBeCloseTo(box.x + box.width / 2, 5);
  144. expect(d.width).toBeGreaterThanOrEqual(box.width);
  145. expect(d.y).toBeGreaterThan(box.y + box.height - 1);
  146. });
  147. it('each line out answers, instead of carrying the whole predicate', () => {
  148. expect(edgeTo(home.id).arm).toBe('yes');
  149. expect(edgeTo(home.id).label).toBe('yes');
  150. expect(edgeTo(welcome.id).arm).toBe('no');
  151. expect(edgeTo(welcome.id).label).toBe('no');
  152. // The line into the deciding box is not an arm of anything.
  153. expect(edgeTo(resolve.id).arm).toBeUndefined();
  154. });
  155. it('labels the arms at rest — and only the arms', () => {
  156. const arms = new Set([...model.edges.values()].filter((e) => e.arm !== undefined).map((e) => e.id));
  157. const pills = placeLabels(model, null, arms);
  158. expect([...pills.pills.values()].map((p) => p.text).sort()).toEqual(['→ no', '→ yes']);
  159. // With nothing asked for, the tree stays unlabelled as it always was.
  160. expect(placeLabels(model, null, false).pills.size).toBe(0);
  161. });
  162. it('keeps a lone arm, and a step reached either way, on a plain line', () => {
  163. // One drawn arm is a guard clause, not a choice.
  164. const only = buildStepsModel(
  165. payload([anchor, resolve, home], [link(anchor, resolve, { kind: 'store' }), link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON)] })])
  166. );
  167. expect(only.decisions).toEqual([]);
  168. expect([...only.edges.values()].every((e) => e.arm === undefined)).toBe(true);
  169. // A connector with a site that runs under NO condition is not exclusively
  170. // an arm — the step happens either way — so it never claims a side.
  171. const both = buildStepsModel(
  172. payload(
  173. [anchor, resolve, home, welcome],
  174. [
  175. link(anchor, resolve, { kind: 'store' }),
  176. link(resolve, home, { kind: 'navigates', when: ON, sites: [site(ON), { file: 'x.ts', line: 9, text: 'push', when: '' }] }),
  177. link(resolve, welcome, { kind: 'navigates', when: `!(${ON})`, sites: [site(`!(${ON})`, true)] }),
  178. ]
  179. )
  180. );
  181. expect(both.decisions).toEqual([]);
  182. });
  183. it('words a switch arm by its own value, and the default by else', () => {
  184. expect(armWords({ on: 'status', arm: "status === 'expired'", form: 'switch' })).toBe("'expired'");
  185. expect(armWords({ on: 'status', arm: 'anything', form: 'switch', not: true })).toBe('else');
  186. expect(armWords({ on: 'ready', arm: 'ready', form: 'if' })).toBe('yes');
  187. expect(armWords({ on: 'ready', arm: '!ready', form: 'if', not: true })).toBe('no');
  188. });
  189. });
  190. describe('words per project', () => {
  191. it('names the same box for an app, an API and a web app', () => {
  192. expect(kindWord('screen', 'app')).toBe('screen');
  193. expect(kindWord('screen', 'api')).toBe('endpoint');
  194. expect(kindWord('screen', 'web')).toBe('page');
  195. // A route that leads with a verb is an endpoint wherever it is.
  196. 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;
  197. expect(kindWord('screen', 'web', endpoint)).toBe('endpoint');
  198. expect(kindWords('store', 'api')).toEqual(['data call', 'data calls']);
  199. expect(kindWords('bridge', 'app')).toEqual(['native call', 'native calls']);
  200. expect(countWords(11, 'effect', 'api')).toBe('11 outside the index');
  201. expect(countWords(1, 'trigger')).toBe('1 handler');
  202. expect(countWords(3, 'trigger')).toBe('3 handlers');
  203. });
  204. it('says what fires a server-side step', () => {
  205. expect(triggerWords({ kind: 'request', name: 'POST', of: '/users', in: 'users.routes.ts', after: ['authenticate', 'validate(…)'] })).toBe('POST /users · after authenticate, validate(…)');
  206. expect(triggerWords({ kind: 'decorator', name: 'Process', of: "'email'", in: 'x.ts' })).toBe("@Process('email')");
  207. expect(triggerWords({ kind: 'load', name: 'GET', of: '/blog/[slug]', in: 'page.tsx' })).toBe('page load · /blog/[slug]');
  208. });
  209. });
  210. describe('row order', () => {
  211. it('lays a row out in the order the server gave, not by id', () => {
  212. const anchor = step('/login', 'screen', 0, { anchor: true });
  213. const a = step('User.findOne', 'effect', 1, { order: 0 });
  214. const b = step('jwt.sign', 'effect', 1, { order: 1 });
  215. const c = step('200', 'effect', 1, { order: 2 });
  216. const d = step('401', 'effect', 1, { order: 3 });
  217. const model = buildStepsModel(payload([anchor, d, c, b, a], [link(anchor, a), link(anchor, b), link(anchor, c), link(anchor, d)]));
  218. const row = model.layout.nodes.filter((n) => n.id !== anchor.id).sort((x, y) => x.x - y.x).map((n) => n.id);
  219. expect(row).toEqual([a.id, b.id, c.id, d.id]);
  220. });
  221. });
  222. describe('a screen laid out by region', () => {
  223. const A = { id: 'component:PanelA', label: 'PanelA' };
  224. const B = { id: 'component:PanelB', label: 'PanelB' };
  225. const anchor = step('/', 'screen', 0, { anchor: true });
  226. const a1 = step('tapSave', 'trigger', 1, { order: 0, region: A });
  227. const a2 = step('tapUndo', 'trigger', 1, { order: 1, region: A });
  228. const a3 = step('saveThing', 'store', 2, { order: 0, region: A, node: ref('saveThing', 'src/things.storage.ts') });
  229. const b1 = step('tapShare', 'trigger', 1, { order: 2, region: B, node: ref('tapShare', 'src/b.tsx') });
  230. const links = [
  231. link(anchor, a1),
  232. link(anchor, a2),
  233. link(anchor, b1),
  234. link(a1, a3, { kind: 'store' }),
  235. link(a1, b1),
  236. // Another region's way into a shared store — a lead-to line like any other.
  237. link(b1, a3, { kind: 'store' }),
  238. ];
  239. const model = buildStepsModel(payload([anchor, a1, a2, b1, a3], links));
  240. const at = (id: string) => model.layout.nodes.find((n) => n.id === id)!;
  241. const between = (id: string, zone: { x: number; width: number }) => {
  242. const n = at(id);
  243. return n.x >= zone.x && n.x + n.width <= zone.x + zone.width;
  244. };
  245. const edge = (from: string, to: string) => model.layout.edges.find((e) => e.source === from && e.target === to)!;
  246. it('names the regions in the order the walk met them, each holding its own boxes', () => {
  247. expect(model.regions!.map((z) => z.label)).toEqual(['PanelA', 'PanelB']);
  248. const [zoneA, zoneB] = model.regions!;
  249. expect(between(a1.id, zoneA!)).toBe(true);
  250. expect(between(a2.id, zoneA!)).toBe(true);
  251. expect(between(a3.id, zoneA!)).toBe(true);
  252. expect(between(b1.id, zoneB!)).toBe(true);
  253. // Side by side, not overlapping: the second region starts past the first.
  254. expect(zoneB!.x).toBeGreaterThanOrEqual(zoneA!.x + zoneA!.width);
  255. });
  256. it('keeps a step above what it sets in motion, inside its region', () => {
  257. expect(at(anchor.id).y).toBeLessThan(at(a1.id).y);
  258. expect(at(a3.id).y).toBeGreaterThan(at(a1.id).y);
  259. // A step that fires something is drawn as a cluster of its own — itself,
  260. // then what it fires under it, stepped in. A step that fires nothing does
  261. // not need one, so the two are no longer on the same line.
  262. expect(at(a3.id).x).toBeGreaterThan(at(a1.id).x);
  263. });
  264. it('spreads the steps that fire nothing along one line, and clusters the ones that do', () => {
  265. // A screen's handlers are siblings, not a hierarchy: giving each its own
  266. // line turned a flat region into a column. Only a step that sets something
  267. // in motion earns a cluster.
  268. const D = { id: 'component:PanelD', label: 'PanelD' };
  269. const root = step('/', 'screen', 0, { anchor: true });
  270. const flat = [0, 1, 2].map((i) => step(`tap${i}`, 'trigger', 1, { order: i, region: D }));
  271. const hub = step('tapRun', 'trigger', 1, { order: 3, region: D });
  272. const under = step('runThing', 'store', 2, { order: 4, region: D, node: ref('runThing', 'src/d.storage.ts') });
  273. const m = buildStepsModel(
  274. payload(
  275. [root, ...flat, hub, under],
  276. [...[...flat, hub].map((s) => link(root, s)), link(hub, under, { kind: 'store' })]
  277. )
  278. );
  279. const y = (id: string) => m.layout.nodes.find((n) => n.id === id)!.y;
  280. expect(new Set(flat.map((s) => y(s.id))).size).toBe(1);
  281. expect(y(hub.id)).toBeGreaterThan(y(flat[0]!.id));
  282. expect(y(under.id)).toBeGreaterThan(y(hub.id));
  283. });
  284. it('settles a region that holds a cycle instead of running to the bound', () => {
  285. // Relaxation over a cyclic graph never stops moving: one real screen sent
  286. // sixty-five of its boxes to rows 294-301 while the rest sat at 0-2.
  287. const E = { id: 'component:PanelE', label: 'PanelE' };
  288. const root = step('/', 'screen', 0, { anchor: true });
  289. const p1 = step('one', 'trigger', 1, { order: 0, region: E });
  290. const p2 = step('two', 'trigger', 2, { order: 1, region: E });
  291. const p3 = step('three', 'trigger', 3, { order: 2, region: E });
  292. const m = buildStepsModel(
  293. payload([root, p1, p2, p3], [link(root, p1), link(p1, p2), link(p2, p3), link(p3, p1)])
  294. );
  295. const ys = [p1, p2, p3].map((s) => m.layout.nodes.find((n) => n.id === s.id)!.y);
  296. const pitch = 40 + m.layerGap;
  297. // Three boxes, so at most three lines of them — not one line per pass.
  298. expect((Math.max(...ys) - Math.min(...ys)) / pitch).toBeLessThanOrEqual(2);
  299. });
  300. it('at rest hides only the screen’s own fan and what points back up; every other lead-to draws', () => {
  301. // The screen's one line into a region lands on the box nearest the region's
  302. // top-left that the screen leads to — `tapUndo`, which fires nothing and so
  303. // sits on the region's first line, not `tapSave`, which clustering moves
  304. // below it because it fires the store.
  305. expect(model.regionEntries).toEqual(new Set([a2.id, b1.id]));
  306. // One line from the screen into each region stands in for its whole fan.
  307. expect(stepEdgeVisible(model, edge(anchor.id, a2.id), null)).toBe(true);
  308. expect(stepEdgeVisible(model, edge(anchor.id, a1.id), null)).toBe(false);
  309. expect(stepEdgeVisible(model, edge(anchor.id, b1.id), null)).toBe(true);
  310. // A region's internal line, and another region's way into a shared step.
  311. expect(stepEdgeVisible(model, edge(a1.id, a3.id), null)).toBe(true);
  312. expect(stepEdgeVisible(model, edge(b1.id, a3.id), null)).toBe(true);
  313. // Two boxes on one row point sideways — back-ish, a click away as everywhere.
  314. expect(stepEdgeVisible(model, edge(a1.id, b1.id), null)).toBe(false);
  315. // Selecting a step brings out everything that touches it, and only that.
  316. expect(stepEdgeVisible(model, edge(a1.id, b1.id), a1.id)).toBe(true);
  317. expect(stepEdgeVisible(model, edge(anchor.id, b1.id), a1.id)).toBe(false);
  318. });
  319. it('stacks a handler above the store it calls, even when both are one hop from the screen', () => {
  320. // Anchor distance is flat inside a region: both of these are depth 1, and
  321. // side by side their link was a level arch, hidden at rest — the store
  322. // floated. The region's own links order its rows instead.
  323. const C = { id: 'component:PanelC', label: 'PanelC' };
  324. const root = step('/', 'screen', 0, { anchor: true });
  325. const h = step('tapCopy', 'trigger', 1, { order: 0, region: C });
  326. const s = step('copyThing', 'store', 1, { order: 1, region: C, node: ref('copyThing', 'src/c.storage.ts') });
  327. const m = buildStepsModel(payload([root, h, s], [link(root, h), link(root, s), link(h, s, { kind: 'store' })]));
  328. const y = (id: string) => m.layout.nodes.find((n) => n.id === id)!.y;
  329. expect(y(s.id)).toBeGreaterThan(y(h.id));
  330. const e = m.layout.edges.find((x) => x.source === h.id && x.target === s.id)!;
  331. expect(e.route).toBe('down');
  332. expect(stepEdgeVisible(m, e, null)).toBe(true);
  333. });
  334. it('a payload without regions keeps the rows, and the Map’s at-rest rule', () => {
  335. 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))]));
  336. expect(plain.regions).toBeNull();
  337. expect(plain.regionEntries).toBeNull();
  338. });
  339. });
  340. describe('a link too far to draw is said in words', () => {
  341. // Two chains in one region, and a link from the tail of the first to the
  342. // tail of the second. The clusters are drawn one under the other, so that
  343. // one link has to cross the whole region — the kind of line that, times a
  344. // hundred, crossed itself six hundred and fifty-two times on a real screen.
  345. const G = { id: 'component:PanelG', label: 'PanelG' };
  346. const anchor = step('/', 'screen', 0, { anchor: true });
  347. const chain = (p: string) =>
  348. [0, 1, 2, 3].map((i) => step(`${p}${i}`, 'trigger', i + 1, { order: i, region: G, node: ref(`${p}${i}`, 'src/g.tsx') }));
  349. const a = chain('a');
  350. const b = chain('b');
  351. const links = [
  352. link(anchor, a[0]!),
  353. link(anchor, b[0]!),
  354. ...a.slice(1).map((s, i) => link(a[i]!, s)),
  355. ...b.slice(1).map((s, i) => link(b[i]!, s)),
  356. // The long one, from the bottom of the first cluster to the bottom of the second.
  357. link(a[3]!, b[3]!),
  358. ];
  359. const model = buildStepsModel(payload([anchor, ...a, ...b], links));
  360. const far = model.layout.edges.find((e) => e.source === a[3]!.id && e.target === b[3]!.id)!;
  361. const near = model.layout.edges.find((e) => e.source === a[0]!.id && e.target === a[1]!.id)!;
  362. it('draws the hop a reader can follow and words the one they cannot', () => {
  363. expect(stepEdgeVisible(model, near, null)).toBe(true);
  364. expect(stepEdgeVisible(model, far, null)).toBe(false);
  365. expect(model.stubbed.has(far.id)).toBe(true);
  366. expect(model.stubbed.has(near.id)).toBe(false);
  367. });
  368. it('says it at BOTH ends, so neither box reads as wired to nothing', () => {
  369. expect(model.stubs.get(a[3]!.id) ?? []).toContainEqual(
  370. expect.objectContaining({ edge: far.id, dir: 'out', label: 'b3' })
  371. );
  372. expect(model.stubs.get(b[3]!.id) ?? []).toContainEqual(
  373. expect.objectContaining({ edge: far.id, dir: 'in', label: 'a3' })
  374. );
  375. });
  376. it('draws every one of a box\u2019s real lines again when it is selected', () => {
  377. expect(stepEdgeVisible(model, far, a[3]!.id)).toBe(true);
  378. expect(stepEdgeVisible(model, far, b[3]!.id)).toBe(true);
  379. // ...and still not for an unrelated selection.
  380. expect(stepEdgeVisible(model, far, a[1]!.id)).toBe(false);
  381. });
  382. it('never words the screen\u2019s own fan \u2014 that is already one line per region', () => {
  383. for (const list of model.stubs.values()) {
  384. for (const stub of list) expect(stub.other).not.toBe(anchor.id);
  385. }
  386. });
  387. it('leaves a picture whose every line is local alone', () => {
  388. const plain = buildStepsModel(
  389. payload([step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1)], [
  390. link(step('/x', 'screen', 0, { anchor: true }), step('go', 'trigger', 1)),
  391. ])
  392. );
  393. expect(plain.stubbed.size).toBe(0);
  394. expect(plain.stubs.size).toBe(0);
  395. });
  396. });
  397. describe('a stub names the box without its kind mark', () => {
  398. it('drops the ⇢ / ⇠ a bridge or an event wears, so the direction reads alone', () => {
  399. const H = { id: 'component:PanelH', label: 'PanelH' };
  400. const anchor = step('/', 'screen', 0, { anchor: true });
  401. const mk = (p: string) => [
  402. step(`${p}0`, 'trigger', 1, { order: 0, region: H, node: ref(`${p}0`, 'src/h.tsx') }),
  403. step(`${p}1`, 'trigger', 2, { order: 1, region: H, node: ref(`${p}1`, 'src/h.tsx') }),
  404. step(`${p}2`, 'trigger', 3, { order: 2, region: H, node: ref(`${p}2`, 'src/h.tsx') }),
  405. step(`${p}3`, 'bridge', 4, { order: 3, region: H, node: ref(`${p}3`, 'ios/H.swift', 'swift') }),
  406. ];
  407. const a = mk('a');
  408. const b = mk('b');
  409. const links = [
  410. link(anchor, a[0]!),
  411. link(anchor, b[0]!),
  412. ...a.slice(1).map((s, i) => link(a[i]!, s)),
  413. ...b.slice(1).map((s, i) => link(b[i]!, s)),
  414. link(a[3]!, b[3]!),
  415. ];
  416. const m = buildStepsModel(payload([anchor, ...a, ...b], links));
  417. expect(stepLabel(b[3]!)).toBe('⇢ b3');
  418. expect(m.stubs.get(a[3]!.id) ?? []).toContainEqual(
  419. expect.objectContaining({ dir: 'out', label: 'b3' })
  420. );
  421. });
  422. });
  423. describe('regions fill the canvas instead of squaring off into rows', () => {
  424. it('lets a short region tuck under another short one, without reordering them', () => {
  425. // Squaring the regions into rows made every row as tall as its tallest
  426. // member: one real screen's canvas came out 44% region and 56% nothing.
  427. const anchor = step('/', 'screen', 0, { anchor: true });
  428. const region = (n: string) => ({ id: `component:${n}`, label: n });
  429. const short = (n: string, order: number) =>
  430. step(n, 'trigger', 1, { order, region: region('R' + n), node: ref(n, `src/${n}.tsx`) });
  431. // One tall region (a chain), then several short ones beside it.
  432. const tallR = region('Tall');
  433. const chain = [0, 1, 2, 3, 4, 5].map((i) =>
  434. step(`t${i}`, 'trigger', i + 1, { order: i, region: tallR, node: ref(`t${i}`, 'src/t.tsx') })
  435. );
  436. const a = short('alpha', 10), b = short('beta', 11), c = short('gamma', 12);
  437. const steps = [anchor, ...chain, a, b, c];
  438. const links = [
  439. ...[chain[0]!, a, b, c].map((s) => link(anchor, s)),
  440. ...chain.slice(1).map((s, i) => link(chain[i]!, s)),
  441. ];
  442. const m = buildStepsModel(payload(steps, links));
  443. const zone = (n: string) => m.regions!.find((z) => z.label === n)!;
  444. const tall = zone('Tall');
  445. // The short regions are laid out after the tall one and do not wait for it.
  446. for (const n of ['Ralpha', 'Rbeta', 'Rgamma']) {
  447. expect(zone(n).y).toBeLessThan(tall.y + tall.height);
  448. }
  449. // …and the order still reads left to right: an earlier region is never
  450. // pushed below a later one.
  451. expect(zone('Ralpha').y).toBeLessThanOrEqual(zone('Rgamma').y);
  452. // The canvas is not taller than the tall region needs it to be.
  453. const H = Math.max(...m.layout.nodes.map((n) => n.y + n.height));
  454. expect(H).toBeLessThan(tall.y + tall.height + 200);
  455. });
  456. });
  457. describe('the width a picture wraps at is tried, not estimated', () => {
  458. it('lets a wide spread run wide instead of wrapping into a column', () => {
  459. // A cluster spends lines on its own structure, so `total width / line
  460. // width` badly under-counts the lines a region takes: a formula tuned on
  461. // that estimate wrapped a 98-box region into a 4,356px column. The widths
  462. // are cheap to try exactly, so they are tried.
  463. const R = { id: 'component:Wide', label: 'Wide' };
  464. const anchor = step('/', 'screen', 0, { anchor: true });
  465. const hub = step('startEverything', 'trigger', 1, { order: 0, region: R, node: ref('startEverything', 'src/w.tsx') });
  466. const leaves = Array.from({ length: 24 }, (_, i) =>
  467. step(`writeSomeValue${i}`, 'store', 2, { order: i + 1, region: R, node: ref(`writeSomeValue${i}`, 'src/w.storage.ts') })
  468. );
  469. const m = buildStepsModel(
  470. payload([anchor, hub, ...leaves], [link(anchor, hub), ...leaves.map((l) => link(hub, l, { kind: 'store' }))])
  471. );
  472. const W = Math.max(...m.layout.nodes.map((n) => n.x + n.width));
  473. const H = Math.max(...m.layout.nodes.map((n) => n.y + n.height));
  474. // At a fixed 720px these twenty-four boxes wrapped into eight lines and the
  475. // picture came out taller than wide; it should now be at least as wide.
  476. expect(W).toBeGreaterThan(H);
  477. });
  478. });