core-menu.spec.ts 9.3 KB

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