ui-steps-program.test.ts 11 KB

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