ui-steps-program.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /**
  2. * The Steps view's second reading: the anchor's body in the code's order.
  3. *
  4. * `buildProgram` is pure over the records the walk makes — no graph, no
  5. * source — so this suite hands it records by hand and reads the block tree
  6. * back. The end-to-end reading over real fixtures is in
  7. * `ui-steps-api-servers.test.ts`; what is pinned here is the FOLD: which sites
  8. * become arms of one decision, what ends an arm, and where a helper is drawn.
  9. */
  10. import { describe, it, expect } from 'vitest';
  11. import type { BranchGuard } from '../src/graph/branch-guards';
  12. import { buildProgram, type ProgramInput, type ProgramSite, type WireBlock, type WireItem } from '../src/ui-server/api/program';
  13. /* ------------------------------------------------------------ material -- */
  14. let nextLine = 1;
  15. /** A guard, with the fields the fold reads: which decision, which arm, how the arm leaves. */
  16. function g(text: string, opts: Partial<BranchGuard> = {}): BranchGuard {
  17. return { text, negated: false, form: 'if', line: 1, branch: `b:${text}`, ...opts };
  18. }
  19. /** A site at the next line, reaching a step. */
  20. function at(step: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite> = {}): ProgramSite {
  21. const line = nextLine++;
  22. return { step, link: `l:${step}`, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra };
  23. }
  24. /** A site that folds into a helper. */
  25. function into(fn: string, guards: BranchGuard[] = [], extra: Partial<ProgramSite> = {}): ProgramSite {
  26. const line = nextLine++;
  27. return { into: fn, at: { line, column: 0, end: { line, column: 40 } }, guards, ...extra };
  28. }
  29. function program(sites: Record<string, ProgramSite[]>, replies: string[] = [], into: Record<string, string> = {}) {
  30. nextLine = 1;
  31. const input: ProgramInput = {
  32. sites: new Map(Object.entries(sites)),
  33. root: 'root',
  34. node: (id) => ({ id, kind: 'function', name: id, qualifiedName: id, file: 'a.ts', line: 1, endLine: 2, language: 'typescript', test: false }),
  35. step: (id) => ({ reply: replies.includes(id), into: into[id] ?? null }),
  36. };
  37. return buildProgram(input);
  38. }
  39. /** The shape of a block, one line per item, indented — what a reader would see. */
  40. function shape(block: WireBlock, indent = ''): string[] {
  41. const out: string[] = [];
  42. for (const item of block) {
  43. if (item.kind === 'step') {
  44. out.push(`${indent}${item.step}${item.again ? ' (again)' : ''}${item.within ? ` inside ${item.within}` : ''}`);
  45. if (item.body) out.push(...shape(item.body, `${indent} `));
  46. } else if (item.kind === 'fork') {
  47. out.push(`${indent}${item.form} ${item.on}`);
  48. for (const arm of item.arms) {
  49. out.push(`${indent} arm ${arm.when}${arm.ends ? ` ends:${arm.ends}` : ''}`);
  50. out.push(...shape(arm.body, `${indent} `));
  51. }
  52. } else if (item.kind === 'block') {
  53. out.push(`${indent}${item.block} ${item.label}${item.again ? ' (again)' : ''}`);
  54. out.push(...shape(item.body, `${indent} `));
  55. } else out.push(`${indent}cut ${item.why}`);
  56. }
  57. return out;
  58. }
  59. /* --------------------------------------------------------------- tests -- */
  60. describe('buildProgram', () => {
  61. it('reads a straight line in the code’s order', () => {
  62. const p = program({ root: [at('a'), at('b'), at('c')] });
  63. expect(shape(p!.root)).toEqual(['a', 'b', 'c']);
  64. });
  65. it('is nothing when the anchor has no body to read', () => {
  66. expect(program({})).toBeNull();
  67. expect(buildProgram({ sites: new Map(), root: null, node: () => null, step: () => null })).toBeNull();
  68. });
  69. it('makes an if and its else two arms of ONE fork', () => {
  70. const cond = 'user && ok';
  71. const p = program({
  72. root: [at('lookup'), at('sign', [g(cond)]), at('200', [g(cond)]), at('401', [g(cond, { negated: true, form: 'else' })])],
  73. });
  74. expect(shape(p!.root)).toEqual([
  75. 'lookup',
  76. 'if user && ok',
  77. ' arm user && ok',
  78. ' sign',
  79. ' 200',
  80. ' arm !(user && ok)',
  81. ' 401',
  82. ]);
  83. const fork = p!.root[1] as Extract<WireItem, { kind: 'fork' }>;
  84. expect(fork.arms).toHaveLength(2);
  85. });
  86. it('ends an arm that answers the request', () => {
  87. const cond = 'user';
  88. const p = program(
  89. { root: [at('200', [g(cond)]), at('401', [g(cond, { negated: true, form: 'else' })])] },
  90. ['200', '401']
  91. );
  92. expect(shape(p!.root)).toEqual(['if user', ' arm user ends:reply', ' 200', ' arm !user ends:reply', ' 401']);
  93. });
  94. it('draws an early exit as the fork’s other arm, with how it leaves', () => {
  95. // `if (!product) { res.status(404); throw }` then the rest — the guard on
  96. // the code AFTER carries the same branch, negated, and how the exit left.
  97. const p = program({
  98. root: [
  99. at('404', [g('!product', { branch: 'b:1' })]),
  100. at('save', [g('!product', { negated: true, form: 'guard', branch: 'b:1', exit: 'throw' })]),
  101. ],
  102. }, ['404']);
  103. expect(shape(p!.root)).toEqual(['if !product', ' arm !product ends:reply', ' 404', ' arm product', ' save']);
  104. });
  105. it('draws an early exit whose arm holds nothing as a terminal', () => {
  106. const p = program({ root: [at('go', [g('busy', { negated: true, form: 'guard', exit: 'return' })])] });
  107. expect(shape(p!.root)).toEqual(['if busy', ' arm busy ends:return', ' arm !busy', ' go']);
  108. });
  109. it('nests forks the way the code nests them', () => {
  110. const outer = g('product', { branch: 'b:outer' });
  111. const inner = g('reviewed', { branch: 'b:inner' });
  112. const p = program(
  113. {
  114. root: [
  115. at('400', [outer, inner]),
  116. at('201', [outer, { ...inner, negated: true, form: 'guard', exit: 'throw' }]),
  117. at('404', [{ ...outer, negated: true, form: 'else' }]),
  118. ],
  119. },
  120. ['400', '201', '404']
  121. );
  122. expect(shape(p!.root)).toEqual([
  123. 'if product',
  124. ' arm product',
  125. ' if reviewed',
  126. ' arm reviewed ends:reply',
  127. ' 400',
  128. ' arm !reviewed ends:reply',
  129. ' 201',
  130. ' arm !product ends:reply',
  131. ' 404',
  132. ]);
  133. });
  134. it('puts every case of one switch under one fork', () => {
  135. const branch = 'b:switch';
  136. const p = program({
  137. root: [
  138. at('a', [g("kind === 'a'", { form: 'case', branch })]),
  139. at('b', [g("kind === 'b'", { form: 'case', branch })]),
  140. at('d', [g('kind: default', { form: 'case', branch })]),
  141. ],
  142. });
  143. expect(shape(p!.root)).toEqual([
  144. "switch kind === 'a'",
  145. " arm kind === 'a'",
  146. ' a',
  147. " arm kind === 'b'",
  148. ' b',
  149. ' arm kind: default',
  150. ' d',
  151. ]);
  152. });
  153. it('keeps two try/catch blocks apart', () => {
  154. const p = program({
  155. root: [
  156. at('first', [g('on error', { form: 'catch', branch: 'b:try1' })]),
  157. at('second', [g('on error', { form: 'catch', branch: 'b:try2' })]),
  158. ],
  159. });
  160. expect(shape(p!.root)).toEqual(['try on error', ' arm on error', ' first', 'try on error', ' arm on error', ' second']);
  161. });
  162. it('draws a folded helper where it is called, and says what it is inside', () => {
  163. const p = program({
  164. root: [into('helper', [], { within: 'res.json' }), at('200')],
  165. helper: [at('sign')],
  166. });
  167. expect(shape(p!.root)).toEqual(['inline via helper', ' sign', '200']);
  168. const block = p!.root[0] as Extract<WireItem, { kind: 'block' }>;
  169. expect(block.within).toBe('res.json');
  170. expect(block.via?.name).toBe('helper');
  171. });
  172. it('puts a call written inside another call’s arguments first', () => {
  173. // `res.json({ token: generateToken(…) })` spans lines 14–21 and the token is
  174. // signed on line 19: the signing happens BEFORE the reply it is part of.
  175. const reply: ProgramSite = { step: '200', at: { line: 14, column: 4, end: { line: 21, column: 6 } }, guards: [] };
  176. const signed: ProgramSite = { step: 'sign', at: { line: 19, column: 13, end: { line: 19, column: 34 } }, guards: [] };
  177. const p = program({ root: [reply, signed] });
  178. expect(shape(p!.root)).toEqual(['sign', '200']);
  179. });
  180. it('reads a function once, however many times it is called', () => {
  181. const p = program({
  182. root: [into('helper'), at('x'), into('helper')],
  183. helper: [at('work')],
  184. });
  185. expect(shape(p!.root)).toEqual(['inline via helper', ' work', 'x', 'inline via helper (again)']);
  186. });
  187. it('reads on into a step the walk entered, and stops at one it did not', () => {
  188. // A step explores from its own function: `store`'s is `storeFn`, whose
  189. // sites are its body. A boundary — another screen, an effect — has none.
  190. const entered = program({ root: [at('store')], storeFn: [at('write')] }, [], { store: 'storeFn' });
  191. expect(shape(entered!.root)).toEqual(['store', ' write']);
  192. const boundary = program({ root: [at('store')], storeFn: [at('write')] });
  193. expect(shape(boundary!.root)).toEqual(['store']);
  194. });
  195. it('says a helper that calls itself was already read', () => {
  196. const p = program({ root: [into('a')], a: [at('x'), into('a')] });
  197. expect(shape(p!.root)).toEqual(['inline via a', ' x', ' inline via a (again)']);
  198. });
  199. it('puts work registered to run later in a block of its own', () => {
  200. const p = program({
  201. root: [at('now'), at('afterwards', [], { trigger: { kind: 'callback', name: 'then', of: null } })],
  202. });
  203. expect(shape(p!.root)).toEqual(['now', 'later later · then', ' afterwards']);
  204. });
  205. it('puts calls started together in one block', () => {
  206. const p = program({
  207. root: [at('a', [], { within: 'Promise.all' }), at('b', [], { within: 'Promise.all' }), at('c')],
  208. });
  209. expect(shape(p!.root)).toEqual(['together together', ' a inside Promise.all', ' b inside Promise.all', 'c']);
  210. });
  211. it('closes a fork when the code leaves it', () => {
  212. const cond = g('ready');
  213. const p = program({ root: [at('inside', [cond]), at('after')] });
  214. expect(shape(p!.root)).toEqual(['if ready', ' arm ready', ' inside', 'after']);
  215. });
  216. });