Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
216 lines
9.3 KiB
TypeScript
216 lines
9.3 KiB
TypeScript
// menuReduce generation gating, auto-close, silent group removal, cyclic
|
|
// highlight movement, stale/no-op reference identity; exactMatch lookup.
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { MenuState, TriggerHit } from '../src/core/contract.ts'
|
|
import { exactMatch, MENU_CLOSED, menuReduce, seedGroups } from '../src/core/menu.ts'
|
|
|
|
const hit = (query = ''): TriggerHit => ({
|
|
trigger: '/',
|
|
query,
|
|
position: 'leading',
|
|
span: { start: 0, end: 1 + query.length, draftRev: 1 },
|
|
})
|
|
|
|
/** Seed sources onto the closed state and open a first generation. */
|
|
function open(sources: readonly string[], h: TriggerHit = hit()): MenuState {
|
|
return menuReduce(seedGroups(MENU_CLOSED, sources), { type: 'hit', hit: h })
|
|
}
|
|
|
|
const item = (name: string) => ({ name })
|
|
|
|
describe('menuReduce hit', () => {
|
|
it('opens a new generation with all groups pending', () => {
|
|
const s = open(['command', 'skill'])
|
|
expect(s.open).toBe(true)
|
|
expect(s.generation).toBe(1)
|
|
expect(s.groups).toEqual([
|
|
{ source: 'command', status: 'pending', items: [] },
|
|
{ source: 'skill', status: 'pending', items: [] },
|
|
])
|
|
expect(s.highlight).toBeNull()
|
|
})
|
|
|
|
it('re-hit resets ready groups to pending under a bumped generation', () => {
|
|
let s = open(['command'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
|
s = menuReduce(s, { type: 'hit', hit: hit('g') })
|
|
expect(s.generation).toBe(2)
|
|
expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [] }])
|
|
expect(s.highlight).toBeNull()
|
|
})
|
|
|
|
it('null hit closes; closing an already-closed state is a no-op reference', () => {
|
|
const s = open(['command'])
|
|
const c = menuReduce(s, { type: 'hit', hit: null })
|
|
expect(c.open).toBe(false)
|
|
expect(c.groups).toEqual([])
|
|
expect(menuReduce(c, { type: 'hit', hit: null })).toBe(c)
|
|
})
|
|
})
|
|
|
|
describe('menuReduce source-settled', () => {
|
|
it('marks the group ready and highlights the first item', () => {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
|
expect(s.groups[1]).toEqual({ source: 'skill', status: 'ready', items: [item('commit')] })
|
|
expect(s.groups[0]!.status).toBe('pending')
|
|
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
|
})
|
|
|
|
it('keeps an existing valid highlight when a later group settles', () => {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
|
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
|
})
|
|
|
|
it('drops settlements from a stale generation by reference', () => {
|
|
let s = open(['command'])
|
|
s = menuReduce(s, { type: 'hit', hit: hit('g') }) // generation 2
|
|
const next = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
|
expect(next).toBe(s)
|
|
})
|
|
|
|
it('drops settlements while closed and for unknown sources by reference', () => {
|
|
const closed = menuReduce(open(['command']), { type: 'close' })
|
|
expect(menuReduce(closed, { type: 'source-settled', generation: 1, source: 'command', items: [] })).toBe(closed)
|
|
const s = open(['command'])
|
|
expect(menuReduce(s, { type: 'source-settled', generation: 1, source: 'ghost', items: [] })).toBe(s)
|
|
})
|
|
|
|
it('treats omitted items as empty', () => {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command' })
|
|
expect(s.groups[0]).toEqual({ source: 'command', status: 'ready', items: [] })
|
|
expect(s.open).toBe(true) // skill still pending
|
|
})
|
|
|
|
it('auto-closes when every group settles ready and empty', () => {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [] })
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [] })
|
|
expect(s.open).toBe(false)
|
|
expect(s.groups).toEqual([])
|
|
})
|
|
|
|
it('stays open when one group is empty but another has items', () => {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [] })
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
|
expect(s.open).toBe(true)
|
|
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
|
})
|
|
})
|
|
|
|
describe('menuReduce source-failed', () => {
|
|
it('silently removes the failed group', () => {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
|
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
|
expect(s.groups.map(g => g.source)).toEqual(['skill'])
|
|
expect(s.open).toBe(true)
|
|
})
|
|
|
|
it('closes when the last group fails', () => {
|
|
let s = open(['command'])
|
|
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
|
expect(s.open).toBe(false)
|
|
})
|
|
|
|
it('closes when the surviving groups are all ready and empty', () => {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [] })
|
|
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
|
expect(s.open).toBe(false)
|
|
})
|
|
|
|
it('moves the highlight off the failed group', () => {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
|
expect(s.highlight).toEqual({ source: 'command', index: 0 })
|
|
s = menuReduce(s, { type: 'source-failed', generation: 1, source: 'command' })
|
|
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
|
})
|
|
|
|
it('drops stale-generation and unknown-source failures by reference', () => {
|
|
const s = open(['command'])
|
|
expect(menuReduce(s, { type: 'source-failed', generation: 0, source: 'command' })).toBe(s)
|
|
expect(menuReduce(s, { type: 'source-failed', generation: 1, source: 'ghost' })).toBe(s)
|
|
})
|
|
})
|
|
|
|
describe('menuReduce move', () => {
|
|
/** Two ready groups: command [goal, model], skill [commit]. */
|
|
function ready(): MenuState {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] })
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
|
return s
|
|
}
|
|
|
|
it('cycles forward across groups and wraps', () => {
|
|
let s = ready()
|
|
s = menuReduce(s, { type: 'move', dir: 1 })
|
|
expect(s.highlight).toEqual({ source: 'command', index: 1 })
|
|
s = menuReduce(s, { type: 'move', dir: 1 })
|
|
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
|
s = menuReduce(s, { type: 'move', dir: 1 })
|
|
expect(s.highlight).toEqual({ source: 'command', index: 0 })
|
|
})
|
|
|
|
it('cycles backward and wraps to the last item', () => {
|
|
let s = ready()
|
|
s = menuReduce(s, { type: 'move', dir: -1 })
|
|
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
|
})
|
|
|
|
it('skips pending groups', () => {
|
|
let s = open(['command', 'skill'])
|
|
s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] })
|
|
s = menuReduce(s, { type: 'move', dir: 1 })
|
|
expect(s.highlight).toEqual({ source: 'skill', index: 0 })
|
|
})
|
|
|
|
it('enters from null highlight at either end', () => {
|
|
const base = { ...ready(), highlight: null }
|
|
expect(menuReduce(base, { type: 'move', dir: 1 }).highlight).toEqual({ source: 'command', index: 0 })
|
|
expect(menuReduce(base, { type: 'move', dir: -1 }).highlight).toEqual({ source: 'skill', index: 0 })
|
|
})
|
|
|
|
it('is a no-op reference when closed, without positions, or single-item', () => {
|
|
const closed = menuReduce(ready(), { type: 'close' })
|
|
expect(menuReduce(closed, { type: 'move', dir: 1 })).toBe(closed)
|
|
const pending = open(['command'])
|
|
expect(menuReduce(pending, { type: 'move', dir: 1 })).toBe(pending)
|
|
let single = open(['command'])
|
|
single = menuReduce(single, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
|
|
expect(menuReduce(single, { type: 'move', dir: 1 })).toBe(single)
|
|
})
|
|
})
|
|
|
|
describe('menuReduce close', () => {
|
|
it('clears everything but keeps the generation for stale-drop', () => {
|
|
let s = open(['command'])
|
|
s = menuReduce(s, { type: 'close' })
|
|
expect(s).toMatchObject({ open: false, hit: null, groups: [], highlight: null, generation: 1 })
|
|
})
|
|
})
|
|
|
|
describe('exactMatch', () => {
|
|
const groups: MenuState['groups'] = [
|
|
{ source: 'command', status: 'ready', items: [item('goal'), item('model')] },
|
|
{ source: 'skill', status: 'pending', items: [] },
|
|
]
|
|
|
|
it('finds an exact name in a ready group', () => {
|
|
expect(exactMatch(groups, 'command', 'model')).toEqual(item('model'))
|
|
})
|
|
|
|
it('returns null on name miss, non-ready group, and unknown source', () => {
|
|
expect(exactMatch(groups, 'command', 'goa')).toBeNull()
|
|
expect(exactMatch(groups, 'skill', 'commit')).toBeNull()
|
|
expect(exactMatch(groups, 'ghost', 'goal')).toBeNull()
|
|
})
|
|
})
|