ui-program-model.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  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 type { WireArm, WireBlock, WireItem, WireProgram, WireStep, WireStepsPayload } from '../ui/src/lib/wire';
  13. /* ------------------------------------------------------------ material -- */
  14. const step = (id: string, over: Partial<WireStep> = {}): WireStep => ({
  15. id,
  16. kind: 'effect',
  17. anchor: false,
  18. node: null,
  19. label: id,
  20. sub: 'response · handler',
  21. depth: 1,
  22. cut: null,
  23. ...over,
  24. });
  25. const arm = (when: string, body: WireBlock, over: Partial<WireArm> = {}): WireArm => ({ when, ends: null, body, ...over });
  26. function payload(steps: WireStep[], root: WireBlock): WireStepsPayload {
  27. return {
  28. anchor: { id: 'anchor', kind: 'route', name: 'POST /login', qualifiedName: 'POST /login', file: 'r.js', line: 1, endLine: 1, language: 'javascript', test: false },
  29. ambiguous: [],
  30. project: 'api',
  31. steps: [step('anchor', { kind: 'anchor', anchor: true, label: 'POST /login' }), ...steps],
  32. links: [],
  33. program: { root, truncated: 0 },
  34. defaultView: 'order',
  35. depth: 8,
  36. limit: 120,
  37. through: false,
  38. truncated: { steps: 0, hubs: 0, chrome: 0 },
  39. index: { lastIndexedAt: null, edges: 0, files: 0 },
  40. timing: { elapsedMs: 1 },
  41. };
  42. }
  43. /** The graph as `from → to` lines, each with what has to hold. */
  44. function shape(root: WireBlock): string[] {
  45. const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
  46. return g.edges.map((e) => `${e.from} → ${e.to}${e.when ? ` · ${lineWords(e)}` : ''}${e.runs.length ? ` [${e.runs.join(', ')}]` : ''}`);
  47. }
  48. function rowsOf(root: WireBlock): Record<string, number> {
  49. const g = orderGraph({ root, truncated: 0 } as WireProgram, 'anchor');
  50. return Object.fromEntries(g.depth);
  51. }
  52. /* --------------------------------------------------------------- tests -- */
  53. describe('the picture in the code’s order', () => {
  54. it('puts one step after the next', () => {
  55. expect(shape([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual(['anchor → a', 'a → b']);
  56. expect(rowsOf([{ kind: 'step', step: 'a' }, { kind: 'step', step: 'b' }])).toEqual({ anchor: 0, a: 1, b: 2 });
  57. });
  58. it('branches both arms off the step before the fork, and says what has to hold', () => {
  59. // proshop's login: look the user up, then sign+answer 200, else answer 401.
  60. const on = 'user && (await user.matchPassword(password))';
  61. const root: WireBlock = [
  62. { kind: 'step', step: 'findOne' },
  63. {
  64. kind: 'fork',
  65. form: 'if',
  66. on,
  67. arms: [
  68. arm(on, [{ kind: 'block', block: 'inline', body: [{ kind: 'step', step: 'sign' }] }, { kind: 'step', step: '200' }], { ends: 'reply' }),
  69. arm(`!(${on})`, [{ kind: 'step', step: '401' }], { not: true, ends: 'reply' }),
  70. ],
  71. },
  72. ];
  73. expect(shape(root)).toEqual([
  74. 'anchor → findOne',
  75. 'findOne → sign · WHEN user AND (await user.matchPassword… [via a helper]',
  76. 'sign → 200',
  77. 'findOne → 401 · WHEN NOT (user && (await user.matchPass…',
  78. ]);
  79. // The 200 sits a row BELOW the signing, which is the whole point.
  80. expect(rowsOf(root)).toEqual({ anchor: 0, findOne: 1, sign: 2, '200': 3, '401': 2 });
  81. });
  82. it('rejoins after an arm that runs on, and stops at one that ends', () => {
  83. const root: WireBlock = [
  84. { kind: 'step', step: 'lookup' },
  85. {
  86. kind: 'fork',
  87. form: 'if',
  88. on: 'ready',
  89. arms: [arm('ready', [{ kind: 'step', step: 'inside' }]), arm('!ready', [{ kind: 'step', step: 'bail' }], { not: true, ends: 'return' })],
  90. },
  91. { kind: 'step', step: 'after' },
  92. ];
  93. expect(shape(root)).toEqual([
  94. 'anchor → lookup',
  95. 'lookup → inside · WHEN ready',
  96. 'lookup → bail · WHEN NOT ready',
  97. 'inside → after',
  98. ]);
  99. });
  100. it('runs on either way past an `if` with no else', () => {
  101. const root: WireBlock = [
  102. { kind: 'step', step: 'lookup' },
  103. { kind: 'fork', form: 'if', on: 'verified', arms: [arm('verified', [{ kind: 'step', step: 'mail' }])] },
  104. { kind: 'step', step: 'reply' },
  105. ];
  106. expect(shape(root)).toEqual([
  107. 'anchor → lookup',
  108. 'lookup → mail · WHEN verified',
  109. 'mail → reply',
  110. 'lookup → reply',
  111. ]);
  112. });
  113. it('reads on into what a step sets in motion before the next step', () => {
  114. const root: WireBlock = [
  115. { kind: 'step', step: 'save', body: [{ kind: 'step', step: 'write' }] },
  116. { kind: 'step', step: 'reply' },
  117. ];
  118. expect(shape(root)).toEqual(['anchor → save', 'save → write', 'write → reply']);
  119. });
  120. it('says the run a line happens inside', () => {
  121. const via = { id: 'f', kind: 'function' as const, name: 'generateToken', qualifiedName: 'generateToken', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
  122. expect(shape([{ kind: 'block', block: 'inline', via, body: [{ kind: 'step', step: 'sign' }] }])).toEqual([
  123. 'anchor → sign [via generateToken]',
  124. ]);
  125. expect(shape([{ kind: 'block', block: 'loop', by: 'item of items', loop: 'each', body: [{ kind: 'step', step: 'save' }] }])).toEqual([
  126. 'anchor → save [for each item of items]',
  127. ]);
  128. });
  129. it('carries on past a helper that answers on every path', () => {
  130. // express-realworld: `login()` throws on each guard and returns on one; the
  131. // handler's own `res.json` still follows the call.
  132. const root: WireBlock = [
  133. {
  134. kind: 'block',
  135. block: 'inline',
  136. body: [{ kind: 'fork', form: 'if', on: 'bad', arms: [arm('bad', [{ kind: 'step', step: '422' }], { ends: 'reply' })] }],
  137. },
  138. { kind: 'step', step: '200' },
  139. ];
  140. expect(shape(root)).toEqual(['anchor → 422 · WHEN bad [via a helper]', 'anchor → 200']);
  141. });
  142. it('lets nothing float: a step the fold could not place follows the anchor', () => {
  143. const g = orderGraph({ root: [{ kind: 'cut', why: 'folded' }], truncated: 1 } as WireProgram, 'anchor');
  144. expect(g.edges).toEqual([]);
  145. });
  146. it('settles the rows of a step reached twice rather than looping', () => {
  147. const root: WireBlock = [{ kind: 'step', step: 'db' }, { kind: 'step', step: 'check' }, { kind: 'step', step: 'db' }];
  148. expect(shape(root)).toEqual(['anchor → db', 'db → check', 'check → db']);
  149. expect(rowsOf(root).db).toBeGreaterThan(0);
  150. });
  151. it('names each kind of run', () => {
  152. const via = { id: 'f', kind: 'function' as const, name: 'gen', qualifiedName: 'gen', file: 'a.js', line: 1, endLine: 2, language: 'javascript', test: false };
  153. const block = (over: Partial<Extract<WireItem, { kind: 'block' }>>) => runWords({ kind: 'block', block: 'inline', body: [], ...over } as Extract<WireItem, { kind: 'block' }>);
  154. expect(block({ via })).toBe('via gen');
  155. expect(block({})).toBe('via a helper');
  156. expect(block({ block: 'later', by: 'then' })).toBe('later · then');
  157. expect(block({ block: 'loop', by: 'item of items', loop: 'each' })).toBe('for each item of items');
  158. expect(block({ block: 'loop', by: 'queue.length', loop: 'while' })).toBe('again while queue.length');
  159. expect(block({ block: 'together', by: 'Promise.all' })).toBe('together · Promise.all');
  160. });
  161. it('builds a picture the canvas can draw, and nothing when there is no body', () => {
  162. const model = buildOrderModel(
  163. payload([step('findOne'), step('200')], [{ kind: 'step', step: 'findOne' }, { kind: 'step', step: '200' }])
  164. );
  165. expect(model).not.toBeNull();
  166. expect([...model!.nodes.keys()].sort()).toEqual(['200', 'anchor', 'findOne']);
  167. expect(model!.layout.nodes).toHaveLength(3);
  168. // The anchor is on top: layer 0 is the bottom.
  169. const layer = (id: string) => model!.layout.nodes.find((n) => n.id === id)!.layer;
  170. expect(layer('anchor')).toBeGreaterThan(layer('findOne'));
  171. expect(layer('findOne')).toBeGreaterThan(layer('200'));
  172. expect(buildOrderModel({ ...payload([], []), program: null })).toBeNull();
  173. });
  174. });