diff --git a/internal/server/web/dist/index.html b/internal/server/web/dist/index.html index e21151f805353981de67a56e42b185b0fc8ecc8c..a3dffa21c5e8d0f550e5531f916732e20cd0bc2e 100644 --- a/internal/server/web/dist/index.html +++ b/internal/server/web/dist/index.html @@ -1 +1,19 @@ -letheSPA not built — run just web-build + + + + + + Lethe + + + + + + + +
+ + diff --git a/web/src/lib/keyboard.test.ts b/web/src/lib/keyboard.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4811327ba87905ead4335e18cd67d40e4e2bedb --- /dev/null +++ b/web/src/lib/keyboard.test.ts @@ -0,0 +1,249 @@ +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 + +function makeCtrl(opts: Opts) { + // Cast to KeyboardOpts — vi.fn() satisfies the shape at runtime. + return createKeyboardController(opts as Parameters[0]) +} + +function makeKey( + key: string, + init: Partial & { 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 → 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