ui-program-model.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. /**
  2. * The Steps picture in the code's order: the graph of what happens next.
  3. *
  4. * The server folds the walk into blocks and forks (`api/program.ts`); this
  5. * turns that into the canvas's graph — one edge per "and then", carrying the
  6. * condition where the code branched, and a row per step counted by how much
  7. * has to happen before it. What is pinned here is exactly that: the shape of
  8. * the picture, which is the thing a reader looks at.
  9. */
  10. import { describe, it, expect } from 'vitest';
  11. import { buildOrderModel, lineWords, orderGraph, runWords } from '../ui/src/lib/program-model';
  12. import { selectionReach, stepEdgeVisible } from '../ui/src/lib/steps-model';
  13. import { placeLabels } from '../ui/src/lib/screens-model';
  14. import type { WireArm, WireBlock, WireItem, WireProgram, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
  15. /* ------------------------------------------------------------ material -- */
  16. const step = (id: string, over: Partial<WireStep> = {}): WireStep => ({
  17. id,
  18. kind: 'effect',
  19. anchor: false,
  20. node: null,
  21. label: id,
  22. sub: 'response · handler',
  23. depth: 1,
  24. cut: null,
  25. ...over,
  26. });
  27. const arm = (when: string, body: WireBlock, over: Partial<WireArm> = {}): WireArm => ({ when, ends: null, body, ...over });
  28. function payload(steps: WireStep[], root: WireBlock): WireStepsPayload {
  29. return {
  30. anchor: { id: 'anchor', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false },
  31. ambiguous: [],
  32. project: 'api',
  33. steps: [step('anchor', { kind: 'anchor', anchor: true, label: 'POST /login' }), ...steps],
  34. links: [],
  35. program: { root, truncated: 0 },
  36. defaultView: 'order',
  37. depth: 8,
  38. limit: 120,
  39. through: false,
  40. truncated: { steps: 0, hubs: 0, chrome: 0 },
  41. index: { lastIndexedAt: null, edges: 0, files: 0 },
  42. timing: { elapsedMs: 1 },
  43. };
  44. }
  45. /** The graph as `from → to` lines, each with what has to hold. */
  46. function shape(root: WireBlock): string[] {
  47. const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
  48. return g.edges.map((e) => `${e.from} → ${e.to}${e.when ? ` · ${lineWords(e)}` : ''}${e.runs.length ? ` [${e.runs.join(', ')}]` : ''}`);
  49. }
  50. function rowsOf(root: WireBlock): Record<string, number> {
  51. const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
  52. return Object.fromEntries(g.depth);
  53. }
  54. /* --------------------------------------------------------------- tests -- */
  55. describe('the picture in the code’s order', () => {
  56. it('puts one step after the next', () => {
  57. expect(shape([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual(['anchor → a', 'a → b']);
  58. expect(rowsOf([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual({ anchor: 0, a: 1, b: 2 });
  59. });
  60. it('diverges both arms from a point that asks the condition once', () => {
  61. // proshop's login: look the user up, then sign+answer 200, else answer 401.
  62. // The decision is ONE choice, so it draws once — a point the arms leave,
  63. // each line saying only which arm it is — not two lines that each carry
  64. // the whole predicate, one of them negated.
  65. const on = 'user && (await user.matchPassword(password))';
  66. const root: WireBlock = [
  67. { kind: 'step', step: 'findOne' },
  68. {
  69. kind: 'fork',
  70. form: 'if',
  71. on,
  72. arms: [
  73. arm(on, [{ kind: 'block', block: 'inline', body: [{ kind: 'step', step: 'sign' }] }, { kind: 'step', step: '200' }], { ends: 'reply' }),
  74. arm(`!(${on})`, [{ kind: 'step', step: '401' }], { not: true, ends: 'reply' }),
  75. ],
  76. },
  77. ];
  78. const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
  79. expect(g.forks).toEqual([{ id: 'fork:0', on, form: 'if' }]);
  80. expect(shape(root)).toEqual([
  81. 'anchor → findOne',
  82. 'findOne → fork:0',
  83. 'fork:0 → sign · yes [via a helper]',
  84. 'sign → 200',
  85. 'fork:0 → 401 · no',
  86. ]);
  87. // The arm's own condition still rides the line, for the hover.
  88. expect(g.edges.find((e) => e.to === '401')!.when).toBe(`!(${on})`);
  89. // The 200 sits a row BELOW the signing, which is the whole point; the
  90. // decision takes a row of its own between the lookup and the arms.
  91. expect(rowsOf(root)).toEqual({ anchor: 0, findOne: 1, 'fork:0': 2, sign: 3, '200': 4, '401': 3 });
  92. });
  93. it('rejoins after an arm that runs on, and stops at one that ends', () => {
  94. const root: WireBlock = [
  95. { kind: 'step', step: 'lookup' },
  96. {
  97. kind: 'fork',
  98. form: 'if',
  99. on: 'ready',
  100. arms: [arm('ready', [{ kind: 'step', step: 'inside' }]), arm('!ready', [{ kind: 'step', step: 'bail' }], { not: true, ends: 'return' })],
  101. },
  102. { kind: 'step', step: 'after' },
  103. ];
  104. expect(shape(root)).toEqual([
  105. 'anchor → lookup',
  106. 'lookup → fork:0',
  107. 'fork:0 → inside · yes',
  108. 'fork:0 → bail · no',
  109. 'inside → after',
  110. ]);
  111. });
  112. it('labels a switch’s arms with their own values, and its default with else', () => {
  113. const root: WireBlock = [
  114. { kind: 'step', step: 'load' },
  115. {
  116. kind: 'fork',
  117. form: 'switch',
  118. on: 'status',
  119. arms: [
  120. arm("status === 'expired'", [{ kind: 'step', step: 'refresh' }]),
  121. arm("status === 'active'", [{ kind: 'step', step: 'serve' }]),
  122. arm("!(status === 'expired' || status === 'active')", [{ kind: 'step', step: 'reject' }], { not: true, ends: 'reply' }),
  123. ],
  124. },
  125. ];
  126. expect(shape(root)).toEqual([
  127. 'anchor → load',
  128. 'load → fork:0',
  129. "fork:0 → refresh · 'expired'",
  130. "fork:0 → serve · 'active'",
  131. 'fork:0 → reject · else',
  132. ]);
  133. });
  134. it('keeps a lone guard on the line — an early exit is not a point', () => {
  135. // `if (!product) throw` — the exit arm is empty; only one arm draws, so
  136. // the condition rides the line exactly as before.
  137. const root: WireBlock = [
  138. { kind: 'step', step: 'lookup' },
  139. {
  140. kind: 'fork',
  141. form: 'if',
  142. on: 'product',
  143. arms: [arm('product', [], { ends: 'throw' }), arm('!product', [{ kind: 'step', step: 'render' }], { not: true })],
  144. },
  145. ];
  146. const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
  147. expect(g.forks).toEqual([]);
  148. expect(shape(root)).toEqual(['anchor → lookup', 'lookup → render · WHEN NOT product']);
  149. });
  150. it('stops claiming a side when both arms reach the same step', () => {
  151. const root: WireBlock = [
  152. { kind: 'step', step: 'check' },
  153. {
  154. kind: 'fork',
  155. form: 'if',
  156. on: 'a',
  157. arms: [
  158. arm('a', [{ kind: 'step', step: 'log' }, { kind: 'step', step: 'go' }]),
  159. arm('!(a)', [{ kind: 'step', step: 'log', again: true }], { not: true }),
  160. ],
  161. },
  162. ];
  163. const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
  164. const toLog = g.edges.find((e) => e.to === 'log')!;
  165. expect(toLog.arm).toBeUndefined();
  166. expect(toLog.when).toBe('a || !(a)');
  167. });
  168. it('runs on either way past an `if` with no else', () => {
  169. const root: WireBlock = [
  170. { kind: 'step', step: 'lookup' },
  171. { kind: 'fork', form: 'if', on: 'verified', arms: [arm('verified', [{ kind: 'step', step: 'mail' }])] },
  172. { kind: 'step', step: 'reply' },
  173. ];
  174. expect(shape(root)).toEqual([
  175. 'anchor → lookup',
  176. 'lookup → mail · WHEN verified',
  177. 'mail → reply',
  178. 'lookup → reply',
  179. ]);
  180. });
  181. it('reads on into what a step sets in motion before the next step', () => {
  182. const root: WireBlock = [
  183. { kind: 'step', step: 'save', body: [{ kind: 'step', step: 'write' }] },
  184. { kind: 'step', step: 'reply' },
  185. ];
  186. expect(shape(root)).toEqual(['anchor → save', 'save → write', 'write → reply']);
  187. });
  188. it('says the run a line happens inside', () => {
  189. const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
  190. expect(shape([{ kind: 'block', block: 'inline', via, body: [{ kind: 'step', step: 'sign' }] }])).toEqual([
  191. 'anchor → sign [via generateToken]',
  192. ]);
  193. expect(shape([{ kind: 'block', block: 'loop', by: 'item of items', loop: 'each', body: [{ kind: 'step', step: 'save' }] }])).toEqual([
  194. 'anchor → save [for each item of items]',
  195. ]);
  196. });
  197. it('carries on past a helper that answers on every path', () => {
  198. // express-realworld: `login()` throws on each guard and returns on one; the
  199. // handler's own `res.json` still follows the call.
  200. const root: WireBlock = [
  201. {
  202. kind: 'block',
  203. block: 'inline',
  204. body: [{ kind: 'fork', form: 'if', on: 'bad', arms: [arm('bad', [{ kind: 'step', step: '422' }], { ends: 'reply' })] }],
  205. },
  206. { kind: 'step', step: '200' },
  207. ];
  208. expect(shape(root)).toEqual(['anchor → 422 · WHEN bad [via a helper]', 'anchor → 200']);
  209. });
  210. it('lets nothing float: a step the fold could not place follows the anchor', () => {
  211. const g = orderGraph({ root: [{ kind: 'cut', why: 'folded' }], truncated: 1 } as WireProgram, 'anchor');
  212. expect(g.edges).toEqual([]);
  213. });
  214. it('settles the rows of a step reached twice rather than looping', () => {
  215. const root: WireBlock = [{ kind: 'step', step: 'db' }, { kind: 'step', step: 'check' }, { kind: 'step', step: 'db' }];
  216. expect(shape(root)).toEqual(['anchor → db', 'db → check', 'check → db']);
  217. expect(rowsOf(root)).toEqual({ anchor: 0, db: 1, check: 2 });
  218. });
  219. it('never spreads a cyclic reading over more rows than it has boxes', () => {
  220. // A helper the code comes back to from inside a decision makes the graph
  221. // cyclic. Relaxing over a cycle never settles — it added a row on every
  222. // pass until the bound, so on a real screen sixteen boxes landed on sixty
  223. // rows and the picture was a 9,000px ribbon of empty space that no fit
  224. // could open on.
  225. const root: WireBlock = [
  226. { kind: 'step', step: 'logout' },
  227. { kind: 'step', step: 'flags' },
  228. {
  229. kind: 'fork',
  230. form: 'if',
  231. on: 'options?.showAlert',
  232. arms: [
  233. arm('options?.showAlert', [{ kind: 'step', step: 'logout', again: true }]),
  234. arm('!options?.showAlert', [{ kind: 'step', step: 'quiet' }], { not: true }),
  235. ],
  236. },
  237. ];
  238. const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
  239. // The cycle is real and still drawn — it is only the ROW that ignores it.
  240. expect(g.edges.some((e) => e.to === 'logout' && e.from.startsWith('fork:'))).toBe(true);
  241. const depths = [...g.depth.values()];
  242. expect(Math.max(...depths)).toBeLessThan(g.depth.size);
  243. // Every row between the top and the deepest holds something.
  244. expect(new Set(depths).size).toBe(Math.max(...depths) + 1);
  245. });
  246. it('names each kind of run', () => {
  247. const via = { id: 'f', kind: 'function' as const, name: 'gen', qualifiedName: 'gen', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
  248. const block = (over: Partial<Extract<WireItem, { kind: 'block' }>>) => runWords({ kind: 'block', block: 'inline', body: [], ...over } as Extract<WireItem, { kind: 'block' }>);
  249. expect(block({ via })).toBe('via gen');
  250. expect(block({})).toBe('via a helper');
  251. expect(block({ block: 'later', by: 'then' })).toBe('later · then');
  252. expect(block({ block: 'loop', by: 'item of items', loop: 'each' })).toBe('for each item of items');
  253. expect(block({ block: 'loop', by: 'queue.length', loop: 'while' })).toBe('again while queue.length');
  254. expect(block({ block: 'together', by: 'Promise.all' })).toBe('together · Promise.all');
  255. });
  256. it('builds a picture the canvas can draw, and nothing when there is no body', () => {
  257. const model = buildOrderModel(
  258. payload([step('findOne'), step('200')], [{ kind: 'step', step: 'findOne' }, { kind: 'step', step: '200' }])
  259. );
  260. expect(model).not.toBeNull();
  261. expect([...model!.nodes.keys()].sort()).toEqual(['200', 'anchor', 'findOne']);
  262. expect(model!.layout.nodes).toHaveLength(3);
  263. // The anchor is on top: layer 0 is the bottom.
  264. const layer = (id: string) => model!.layout.nodes.find((n) => n.id === id)!.layer;
  265. expect(layer('anchor')).toBeGreaterThan(layer('findOne'));
  266. expect(layer('findOne')).toBeGreaterThan(layer('200'));
  267. expect(buildOrderModel({ ...payload([], []), program: null })).toBeNull();
  268. });
  269. it('draws a decision as a point, and the selection reaches through it', () => {
  270. const root: WireBlock = [
  271. { kind: 'step', step: 'lookup' },
  272. {
  273. kind: 'fork',
  274. form: 'if',
  275. on: 'ready',
  276. arms: [arm('ready', [{ kind: 'step', step: 'inside' }]), arm('!ready', [{ kind: 'step', step: 'bail' }], { not: true, ends: 'return' })],
  277. },
  278. ];
  279. const model = buildOrderModel(payload([step('lookup'), step('inside'), step('bail')], root))!;
  280. expect(model.forks!.get('fork:0')).toEqual({ id: 'fork:0', on: 'ready', form: 'if', label: 'ready?' });
  281. // The point sits between the step before the fork and the arms; it is not a step.
  282. const at = (id: string) => model.layout.nodes.find((n) => n.id === id)!;
  283. expect(at('fork:0').y).toBeGreaterThan(at('lookup').y);
  284. expect(at('fork:0').y).toBeLessThan(at('inside').y);
  285. expect(model.nodes.has('fork:0')).toBe(false);
  286. expect(model.counts.effect).toBe(3);
  287. // The lines out of it say the arm; the line into it says nothing.
  288. const label = (to: string) => [...model.edges.values()].find((e) => e.to === to)!.label;
  289. expect(label('fork:0')).toBe('');
  290. expect(label('inside')).toBe('yes');
  291. expect(label('bail')).toBe('no');
  292. // At rest the arms are labelled — the conditions are this picture's content.
  293. const pills = placeLabels(model, null, true);
  294. expect([...pills.pills.values()].map((p) => p.text).sort()).toEqual(['→ no', '→ yes']);
  295. // Selecting the step before the decision reaches through the point: the
  296. // arms' lines light, instead of dying at a box the reader cannot click.
  297. const reach = selectionReach(model, 'lookup');
  298. expect(reach.has('fork:0')).toBe(true);
  299. const armEdge = model.layout.edges.find((e) => e.source === 'fork:0' && e.target === 'inside')!;
  300. expect(stepEdgeVisible(model, armEdge, 'lookup')).toBe(true);
  301. expect(stepEdgeVisible(model, armEdge, 'lookup', reach)).toBe(true);
  302. // …and selecting an arm lights its sibling, through the same point.
  303. const sibling = model.layout.edges.find((e) => e.source === 'fork:0' && e.target === 'bail')!;
  304. expect(stepEdgeVisible(model, sibling, 'inside')).toBe(true);
  305. });
  306. });