core-menu.client.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import { describe, expect, it } from 'vitest'
  2. import type { MenuState, TriggerHit } from '../src/core/contract.ts'
  3. import { exactMatch, MENU_CLOSED, menuReduce, seedGroups } from '../src/core/menu.ts'
  4. const hit = (query = ''): TriggerHit => ({
  5. trigger: '/',
  6. query,
  7. quoted: false,
  8. position: 'leading',
  9. span: { start: 0, end: 1 + query.length, draftRev: 1 },
  10. })
  11. function open(sources: readonly string[], h: TriggerHit = hit()): MenuState {
  12. return menuReduce(seedGroups(MENU_CLOSED, sources.map(name => ({ name }))), { type: 'hit', hit: h })
  13. }
  14. const item = (name: string) => ({ name })
  15. /** Two ready groups: command [goal, model], skill [commit]. */
  16. function ready(): MenuState {
  17. let s = open(['command', 'skill'])
  18. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] })
  19. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
  20. return s
  21. }
  22. describe('menuReduce hit', () => {
  23. it('opens a new generation with all groups pending', () => {
  24. const s = open(['command', 'skill'])
  25. expect(s.open).toBe(true)
  26. expect(s.generation).toBe(1)
  27. expect(s.groups).toEqual([
  28. { source: 'command', status: 'pending', items: [] },
  29. { source: 'skill', status: 'pending', items: [] },
  30. ])
  31. expect(s.highlight).toBeNull()
  32. })
  33. it('re-hit resets ready groups to pending under a bumped generation, keeping items and highlight', () => {
  34. let s = open(['command'])
  35. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
  36. s = menuReduce(s, { type: 'hit', hit: hit('g') })
  37. expect(s.generation).toBe(2)
  38. // Stale-while-revalidate: the previous query's items stay until the new
  39. // generation settles and replaces them, and the highlight stays parked
  40. // instead of blinking off between keystrokes.
  41. expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [item('goal')] }])
  42. expect(s.highlight).toEqual({ source: 'command', index: 0 })
  43. s = menuReduce(s, { type: 'source-settled', generation: 2, source: 'command', items: [item('grep')] })
  44. expect(s.groups).toEqual([{ source: 'command', status: 'ready', items: [item('grep')] }])
  45. expect(s.highlight).toEqual({ source: 'command', index: 0 })
  46. })
  47. it('preserves a hidden group title through re-hit and settlement', () => {
  48. let s = menuReduce(seedGroups(MENU_CLOSED, [{ name: 'reference', showGroupTitle: false }]), { type: 'hit', hit: hit() })
  49. expect(s.groups[0]).toMatchObject({ source: 'reference', showGroupTitle: false, status: 'pending' })
  50. s = menuReduce(s, { type: 'hit', hit: hit('r') })
  51. s = menuReduce(s, { type: 'source-settled', generation: 2, source: 'reference', items: [item('README.md')] })
  52. expect(s.groups[0]).toMatchObject({ source: 'reference', showGroupTitle: false, status: 'ready' })
  53. })
  54. it('null hit closes; closing an already-closed state is a no-op reference', () => {
  55. const s = open(['command'])
  56. const c = menuReduce(s, { type: 'hit', hit: null })
  57. expect(c.open).toBe(false)
  58. expect(c.groups).toEqual([])
  59. expect(menuReduce(c, { type: 'hit', hit: null })).toBe(c)
  60. })
  61. })
  62. describe('menuReduce source-settled', () => {
  63. it('marks the group ready and highlights the first item', () => {
  64. let s = open(['command', 'skill'])
  65. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
  66. expect(s.groups[1]).toEqual({ source: 'skill', status: 'ready', items: [item('commit')] })
  67. expect(s.groups[0]!.status).toBe('pending')
  68. expect(s.highlight).toEqual({ source: 'skill', index: 0 })
  69. })
  70. it('keeps an existing valid highlight when a later group settles', () => {
  71. let s = open(['command', 'skill'])
  72. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
  73. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
  74. expect(s.highlight).toEqual({ source: 'skill', index: 0 })
  75. })
  76. it('drops settlements from a stale generation by reference', () => {
  77. let s = open(['command'])
  78. s = menuReduce(s, { type: 'hit', hit: hit('g') }) // generation 2
  79. const next = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
  80. expect(next).toBe(s)
  81. })
  82. it('drops settlements while closed and for unknown sources by reference', () => {
  83. const closed = menuReduce(open(['command']), { type: 'close' })
  84. expect(menuReduce(closed, { type: 'source-settled', generation: 1, source: 'command', items: [] })).toBe(closed)
  85. const s = open(['command'])
  86. expect(menuReduce(s, { type: 'source-settled', generation: 1, source: 'ghost', items: [] })).toBe(s)
  87. })
  88. it('treats omitted items as empty', () => {
  89. let s = open(['command', 'skill'])
  90. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command' })
  91. expect(s.groups[0]).toEqual({ source: 'command', status: 'ready', items: [] })
  92. expect(s.open).toBe(true) // skill still pending
  93. })
  94. it('auto-closes when every group settles ready and empty', () => {
  95. let s = open(['command', 'skill'])
  96. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [] })
  97. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [] })
  98. expect(s.open).toBe(false)
  99. expect(s.groups).toEqual([])
  100. })
  101. it('stays open when one group is empty but another has items', () => {
  102. let s = open(['command', 'skill'])
  103. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [] })
  104. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
  105. expect(s.open).toBe(true)
  106. expect(s.highlight).toEqual({ source: 'skill', index: 0 })
  107. })
  108. })
  109. describe('menuReduce source-failed', () => {
  110. it('silently removes the failed group', () => {
  111. let s = open(['command', 'skill'])
  112. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
  113. s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
  114. expect(s.groups.map(g => g.source)).toEqual(['skill'])
  115. expect(s.open).toBe(true)
  116. })
  117. it('closes when the last group fails', () => {
  118. let s = open(['command'])
  119. s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
  120. expect(s.open).toBe(false)
  121. })
  122. it('closes when the surviving groups are all ready and empty', () => {
  123. let s = open(['command', 'skill'])
  124. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [] })
  125. s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
  126. expect(s.open).toBe(false)
  127. })
  128. it('moves the highlight off the failed group', () => {
  129. let s = open(['command', 'skill'])
  130. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
  131. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
  132. expect(s.highlight).toEqual({ source: 'command', index: 0 })
  133. s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
  134. expect(s.highlight).toEqual({ source: 'skill', index: 0 })
  135. })
  136. it('drops stale-generation and unknown-source failures by reference', () => {
  137. const s = open(['command'])
  138. expect(menuReduce(s, { type: 'source-failed', generation: 0, source: 'command' })).toBe(s)
  139. expect(menuReduce(s, { type: 'source-failed', generation: 1, source: 'ghost' })).toBe(s)
  140. })
  141. })
  142. describe('menuReduce move', () => {
  143. it('cycles forward across groups and wraps', () => {
  144. let s = ready()
  145. s = menuReduce(s, { type: 'move', dir: 1 })
  146. expect(s.highlight).toEqual({ source: 'command', index: 1 })
  147. s = menuReduce(s, { type: 'move', dir: 1 })
  148. expect(s.highlight).toEqual({ source: 'skill', index: 0 })
  149. s = menuReduce(s, { type: 'move', dir: 1 })
  150. expect(s.highlight).toEqual({ source: 'command', index: 0 })
  151. })
  152. it('cycles backward and wraps to the last item', () => {
  153. let s = ready()
  154. s = menuReduce(s, { type: 'move', dir: -1 })
  155. expect(s.highlight).toEqual({ source: 'skill', index: 0 })
  156. })
  157. it('skips pending groups', () => {
  158. let s = open(['command', 'skill'])
  159. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
  160. s = menuReduce(s, { type: 'move', dir: 1 })
  161. expect(s.highlight).toEqual({ source: 'skill', index: 0 })
  162. })
  163. it('enters from null highlight at either end', () => {
  164. const base = { ...ready(), highlight: null }
  165. expect(menuReduce(base, { type: 'move', dir: 1 }).highlight).toEqual({ source: 'command', index: 0 })
  166. expect(menuReduce(base, { type: 'move', dir: -1 }).highlight).toEqual({ source: 'skill', index: 0 })
  167. })
  168. it('is a no-op reference when closed, without positions, or single-item', () => {
  169. const closed = menuReduce(ready(), { type: 'close' })
  170. expect(menuReduce(closed, { type: 'move', dir: 1 })).toBe(closed)
  171. const pending = open(['command'])
  172. expect(menuReduce(pending, { type: 'move', dir: 1 })).toBe(pending)
  173. let single = open(['command'])
  174. single = menuReduce(single, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
  175. expect(menuReduce(single, { type: 'move', dir: 1 })).toBe(single)
  176. })
  177. })
  178. describe('menuReduce hover', () => {
  179. it('parks the highlight on the hovered ready item', () => {
  180. const s = menuReduce(ready(), { type: 'hover', source: 'skill', index: 0 })
  181. expect(s.highlight).toEqual({ source: 'skill', index: 0 })
  182. })
  183. it('is a no-op reference when closed, on invalid targets, and on the current highlight', () => {
  184. const closed = menuReduce(ready(), { type: 'close' })
  185. expect(menuReduce(closed, { type: 'hover', source: 'command', index: 0 })).toBe(closed)
  186. const s = ready()
  187. expect(menuReduce(s, { type: 'hover', source: 'ghost', index: 0 })).toBe(s)
  188. expect(menuReduce(s, { type: 'hover', source: 'command', index: 5 })).toBe(s)
  189. expect(menuReduce(s, { type: 'hover', source: 'command', index: 0 })).toBe(s)
  190. })
  191. it('ignores a hover into a still-pending group', () => {
  192. let s = open(['command', 'skill'])
  193. s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
  194. expect(menuReduce(s, { type: 'hover', source: 'skill', index: 0 })).toBe(s)
  195. })
  196. })
  197. describe('menuReduce close', () => {
  198. it('clears everything but keeps the generation for stale-drop', () => {
  199. let s = open(['command'])
  200. s = menuReduce(s, { type: 'close' })
  201. expect(s).toMatchObject({ open: false, hit: null, groups: [], highlight: null, generation: 1 })
  202. })
  203. })
  204. describe('exactMatch', () => {
  205. const groups: MenuState['groups'] = [
  206. { source: 'command', status: 'ready', items: [item('goal'), item('model')] },
  207. { source: 'skill', status: 'pending', items: [] },
  208. ]
  209. it('finds an exact name in a ready group', () => {
  210. expect(exactMatch(groups, 'command', 'model')).toEqual(item('model'))
  211. })
  212. it('returns null on name miss, non-ready group, and unknown source', () => {
  213. expect(exactMatch(groups, 'command', 'goa')).toBeNull()
  214. expect(exactMatch(groups, 'skill', 'commit')).toBeNull()
  215. expect(exactMatch(groups, 'ghost', 'goal')).toBeNull()
  216. })
  217. })