ui-trails-model.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. /**
  2. * What a saved trail's row says, without a browser (CG-60).
  3. *
  4. * The endpoint's own behaviour is pinned in `ui-trails.test.ts` against a real
  5. * index; this is the wording layer, and the rule it exists to protect is that
  6. * **a trail that has decayed never reads as intact**. A saved trail is somebody's
  7. * explanation of a codebase that has since moved underneath it, and a row that
  8. * prints "6 hops" while two of them are gone is a lie by omission at exactly the
  9. * moment the trail needs fixing.
  10. */
  11. import { describe, it, expect } from 'vitest';
  12. import {
  13. hopStatusWord,
  14. isOpenable,
  15. replacedTrail,
  16. trailDecay,
  17. trailExport,
  18. trailMeta,
  19. trailNameProblem,
  20. trailOpens,
  21. trailTitle,
  22. } from '../ui/src/lib/trails-model';
  23. import type { WireTrail, WireTrailHop, WireTrailHopStatus } from '../ui/src/lib/wire';
  24. function hop(
  25. name: string,
  26. status: WireTrailHopStatus = 'ok',
  27. dir: WireTrailHop['dir'] = 'down'
  28. ): WireTrailHop {
  29. const alive = status !== 'missing';
  30. return {
  31. dir,
  32. name,
  33. qualifiedName: name,
  34. kind: 'function',
  35. savedFile: 'src/a.ts',
  36. savedLine: 10,
  37. status,
  38. id: alive ? `function:${name}` : null,
  39. file: alive ? 'src/a.ts' : null,
  40. line: alive ? 10 : null,
  41. note: status === 'ok' ? null : `${name} ${status}`,
  42. };
  43. }
  44. function trail(hops: WireTrailHop[], over: Partial<WireTrail> = {}): WireTrail {
  45. const resolved = hops.filter((h) => h.id !== null);
  46. return {
  47. id: 'a-walk',
  48. name: 'A walk',
  49. note: '',
  50. author: 'Ada',
  51. createdAt: '2026-08-01T00:00:00.000Z',
  52. updatedAt: '2026-08-02T00:00:00.000Z',
  53. hops,
  54. resolved: resolved.length,
  55. intact: hops.every((h) => h.status === 'ok'),
  56. encoded: resolved.length > 0 ? resolved.map((h) => `d${h.id}`).join(',') : null,
  57. openFrom: 1,
  58. openCount: resolved.length,
  59. openId: resolved.length > 0 ? (resolved[resolved.length - 1] as WireTrailHop).id : null,
  60. ...over,
  61. };
  62. }
  63. describe('trailMeta', () => {
  64. it('reports the SAVED length, whatever became of the hops', () => {
  65. const decayed = trail([hop('a', 'ok', 'start'), hop('b', 'missing'), hop('c')]);
  66. expect(trailMeta(decayed)).toBe('3 hops · Ada');
  67. });
  68. it('drops the author when there is not one', () => {
  69. expect(trailMeta(trail([hop('a', 'ok', 'start')], { author: '' }))).toBe('1 hop');
  70. });
  71. });
  72. describe('trailDecay', () => {
  73. it('is null for a trail nothing has happened to', () => {
  74. expect(trailDecay(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
  75. });
  76. it('warns about hops that are gone, naming them', () => {
  77. const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('gone', 'missing')]));
  78. expect(decay?.tone).toBe('warn');
  79. expect(decay?.text).toContain('1 hop moved or renamed');
  80. expect(decay?.text).toContain('gone');
  81. });
  82. it('caps how many it names', () => {
  83. const hops = ['a', 'b', 'c', 'd', 'e'].map((n) => hop(n, 'missing'));
  84. const decay = trailDecay(trail(hops));
  85. expect(decay?.text).toContain('and 2 more');
  86. });
  87. it('notes a move without warning about it — a moved hop still opens', () => {
  88. const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'moved')]));
  89. expect(decay?.tone).toBe('note');
  90. expect(decay?.text).toContain('moved to another file');
  91. });
  92. it('puts a missing hop ahead of a merely moved one', () => {
  93. const decay = trailDecay(trail([hop('m', 'moved'), hop('g', 'missing')]));
  94. expect(decay?.text).toContain('moved or renamed');
  95. });
  96. it('warns about an ambiguous hop — the trail may no longer mean what it said', () => {
  97. const decay = trailDecay(trail([hop('a', 'ok', 'start'), hop('b', 'ambiguous')]));
  98. expect(decay?.tone).toBe('warn');
  99. expect(decay?.text).toContain('more than one symbol');
  100. });
  101. });
  102. describe('trailOpens', () => {
  103. it('says nothing when the whole trail opens', () => {
  104. expect(trailOpens(trail([hop('a', 'ok', 'start'), hop('b')]))).toBeNull();
  105. });
  106. it('names the range when only part of it does', () => {
  107. const partial = trail([hop('a'), hop('b'), hop('c')], {
  108. openFrom: 2,
  109. openCount: 2,
  110. });
  111. expect(trailOpens(partial)).toBe('Opens hops 2–3 of 3.');
  112. });
  113. it('says so plainly when nothing resolves', () => {
  114. const dead = trail([hop('a', 'missing')], { encoded: null, openCount: 0, openId: null });
  115. expect(trailOpens(dead)).toContain('None of this trail resolves');
  116. expect(isOpenable(dead)).toBe(false);
  117. });
  118. });
  119. describe('trailTitle', () => {
  120. it('draws the whole walk with its arrows, and when it was saved', () => {
  121. const walked = trail([hop('a', 'ok', 'start'), hop('b', 'ok', 'down'), hop('c', 'ok', 'up')]);
  122. expect(trailTitle(walked)).toBe('a → b ← c — saved 2026-08-02');
  123. });
  124. });
  125. describe('saving', () => {
  126. it('refuses an empty or over-long name before the round-trip', () => {
  127. expect(trailNameProblem(' ', 120)).toContain('name');
  128. expect(trailNameProblem('x'.repeat(121), 120)).toContain('too long');
  129. expect(trailNameProblem('ok', 120)).toBeNull();
  130. });
  131. it('spots the trail a name would replace, whitespace and all', () => {
  132. const list = [trail([hop('a', 'ok', 'start')], { name: 'A walk' })];
  133. expect(replacedTrail(' A walk ', list)?.name).toBe('A walk');
  134. expect(replacedTrail('Another walk', list)).toBeNull();
  135. });
  136. });
  137. describe('trailExport', () => {
  138. it('exports the SAVED identity of each hop, not today’s resolution', () => {
  139. const moved = trail([hop('a', 'ok', 'start'), hop('b', 'moved')]);
  140. const raw = JSON.parse(trailExport(moved));
  141. expect(raw.version).toBe(1);
  142. // `savedFile`, so dropping the file into another checkout re-runs the same
  143. // resolution rather than baking this index's answer in.
  144. expect(raw.hops[1].file).toBe('src/a.ts');
  145. expect(raw.hops[1].qualifiedName).toBe('b');
  146. expect(raw.hops.map((h: { dir: string }) => h.dir)).toEqual(['start', 'down']);
  147. });
  148. it('survives a hop with no id at all', () => {
  149. const raw = JSON.parse(trailExport(trail([hop('gone', 'missing')])));
  150. expect(raw.hops[0].id).toBe('');
  151. });
  152. });
  153. describe('hopStatusWord', () => {
  154. it('has a word for every status', () => {
  155. for (const status of ['ok', 'moved', 'ambiguous', 'missing'] as const) {
  156. expect(hopStatusWord(status)).toBeTruthy();
  157. }
  158. });
  159. });