import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { createKeyboardController } from './keyboard'
import type { RouteName } from './keyboard'
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeOpts() {
const opts = {
go: vi.fn(),
openPalette: vi.fn(),
closePalette: vi.fn(),
isPaletteOpen: vi.fn().mockReturnValue(false) as () => boolean,
cursor: {
move: vi.fn(),
activate: vi.fn(),
},
}
return opts
}
type Opts = ReturnType<typeof makeOpts>
function makeCtrl(opts: Opts) {
// Cast to KeyboardOpts — vi.fn() satisfies the shape at runtime.
return createKeyboardController(opts as Parameters<typeof createKeyboardController>[0])
}
function makeKey(
key: string,
init: Partial<KeyboardEventInit> & { target?: EventTarget } = {},
): KeyboardEvent {
const { target: tgt, ...rest } = init
const e = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...rest })
if (tgt) {
Object.defineProperty(e, 'target', { value: tgt, configurable: true })
}
return e
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('g + letter navigation', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('g then h within 800 ms → go("home") once; gPending reset', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('g'))
vi.advanceTimersByTime(400) // still within 800 ms window
ctrl.onKeyDown(makeKey('h'))
expect(opts.go).toHaveBeenCalledOnce()
expect(opts.go).toHaveBeenCalledWith('home' satisfies RouteName)
// gPending should be cleared — a second 'h' without 'g' should NOT call go
opts.go.mockClear()
ctrl.onKeyDown(makeKey('h'))
expect(opts.go).not.toHaveBeenCalled()
})
it('g then idle 1 s, then h → no call', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('g'))
vi.advanceTimersByTime(1000) // timeout fires, gPending cleared
ctrl.onKeyDown(makeKey('h'))
expect(opts.go).not.toHaveBeenCalled()
})
it('g then p → go("projects")', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('g'))
ctrl.onKeyDown(makeKey('p'))
expect(opts.go).toHaveBeenCalledWith('projects' satisfies RouteName)
})
it('g then s → go("stats")', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('g'))
ctrl.onKeyDown(makeKey('s'))
expect(opts.go).toHaveBeenCalledWith('stats' satisfies RouteName)
})
it('g then i → go("health")', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('g'))
ctrl.onKeyDown(makeKey('i'))
expect(opts.go).toHaveBeenCalledWith('health' satisfies RouteName)
})
it('teardown clears pending timer', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('g'))
ctrl.teardown() // should not throw; timer cleared
vi.advanceTimersByTime(1000)
ctrl.onKeyDown(makeKey('h'))
expect(opts.go).not.toHaveBeenCalled()
})
})
describe('j / k cursor movement', () => {
it('j while body is target → cursor.move(1)', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('j', { target: document.body }))
expect(opts.cursor.move).toHaveBeenCalledWith(1)
})
it('k while body is target → cursor.move(-1)', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('k', { target: document.body }))
expect(opts.cursor.move).toHaveBeenCalledWith(-1)
})
it('j while target is <input> → no cursor.move', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
const input = document.createElement('input')
ctrl.onKeyDown(makeKey('j', { target: input }))
expect(opts.cursor.move).not.toHaveBeenCalled()
})
it('j while target is <textarea> → no cursor.move', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
const ta = document.createElement('textarea')
ctrl.onKeyDown(makeKey('j', { target: ta }))
expect(opts.cursor.move).not.toHaveBeenCalled()
})
it('j while target is [contenteditable] → no cursor.move', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
const div = document.createElement('div')
div.setAttribute('contenteditable', 'true')
ctrl.onKeyDown(makeKey('j', { target: div }))
expect(opts.cursor.move).not.toHaveBeenCalled()
})
it('j while palette is open → no cursor.move', () => {
const opts = makeOpts()
opts.isPaletteOpen = vi.fn().mockReturnValue(true)
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('j', { target: document.body }))
expect(opts.cursor.move).not.toHaveBeenCalled()
})
})
describe('⌘K / Ctrl+K', () => {
it('⌘K → openPalette called, preventDefault', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
const e = makeKey('k', { metaKey: true })
const spy = vi.spyOn(e, 'preventDefault')
ctrl.onKeyDown(e)
expect(opts.openPalette).toHaveBeenCalledOnce()
expect(spy).toHaveBeenCalledOnce()
})
it('Ctrl+K → openPalette called, preventDefault', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
const e = makeKey('k', { ctrlKey: true })
const spy = vi.spyOn(e, 'preventDefault')
ctrl.onKeyDown(e)
expect(opts.openPalette).toHaveBeenCalledOnce()
expect(spy).toHaveBeenCalledOnce()
})
})
describe('Escape', () => {
it('Esc while isPaletteOpen()===true → closePalette called', () => {
const opts = makeOpts()
opts.isPaletteOpen = vi.fn().mockReturnValue(true)
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('Escape'))
expect(opts.closePalette).toHaveBeenCalledOnce()
})
it('Esc while palette closed → closePalette NOT called', () => {
const opts = makeOpts()
// isPaletteOpen already returns false by default
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('Escape'))
expect(opts.closePalette).not.toHaveBeenCalled()
})
})
describe('Enter', () => {
it('Enter while palette closed + target is body → cursor.activate()', () => {
const opts = makeOpts()
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('Enter', { target: document.body }))
expect(opts.cursor.activate).toHaveBeenCalledOnce()
})
it('Enter while palette open → cursor.activate NOT called', () => {
const opts = makeOpts()
opts.isPaletteOpen = vi.fn().mockReturnValue(true)
const ctrl = makeCtrl(opts)
ctrl.onKeyDown(makeKey('Enter', { target: document.body }))
expect(opts.cursor.activate).not.toHaveBeenCalled()
})
})