feat: MVP-4 Sprint 1 - Query parser, real-time search, keyboard navigation

- Add query-parser.ts with AST for type:, tag:, is:favorite, is:pinned filters
- Integrate parser into search API
- Add real-time search with 300ms debounce in search-bar
- Add keyboard navigation (↑↓ Enter ESC) for Spotlight-like UX
- Add dropdown results display with loading state
- Add 22 tests for query parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 17:33:17 -03:00
parent d5c418c84f
commit 9af25927b7
7 changed files with 650 additions and 28 deletions

View File

@@ -0,0 +1,145 @@
import { parseQuery, QueryAST } from '@/lib/query-parser'
describe('query-parser', () => {
describe('basic text queries', () => {
it('returns text with no filters for simple text', () => {
const result = parseQuery('docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({})
})
it('preserves multi-word text', () => {
const result = parseQuery('hello world')
expect(result.text).toBe('hello world')
expect(result.filters).toEqual({})
})
})
describe('type filter', () => {
it('extracts type filter from beginning of query', () => {
const result = parseQuery('type:command docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ type: 'command' })
})
it('handles query with only type filter', () => {
const result = parseQuery('type:snippet')
expect(result.text).toBe('')
expect(result.filters).toEqual({ type: 'snippet' })
})
it('extracts type filter from end of query', () => {
const result = parseQuery('docker type:command')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ type: 'command' })
})
})
describe('tag filter', () => {
it('extracts tag filter with text', () => {
const result = parseQuery('tag:api error')
expect(result.text).toBe('error')
expect(result.filters).toEqual({ tag: 'api' })
})
it('handles query with only tag filter', () => {
const result = parseQuery('tag:backend')
expect(result.text).toBe('')
expect(result.filters).toEqual({ tag: 'backend' })
})
})
describe('combined filters', () => {
it('parses multiple filters together', () => {
const result = parseQuery('docker tag:backend type:command')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ type: 'command', tag: 'backend' })
})
it('handles type, tag, and isFavorite combined', () => {
const result = parseQuery('type:snippet tag:python is:favorite')
expect(result.text).toBe('')
expect(result.filters).toEqual({ type: 'snippet', tag: 'python', isFavorite: true })
})
})
describe('boolean filters', () => {
it('extracts is:favorite filter', () => {
const result = parseQuery('is:favorite docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ isFavorite: true })
})
it('handles is:pinned filter alone', () => {
const result = parseQuery('is:pinned')
expect(result.text).toBe('')
expect(result.filters).toEqual({ isPinned: true })
})
it('handles both boolean filters with text', () => {
const result = parseQuery('is:favorite is:pinned docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ isFavorite: true, isPinned: true })
})
})
describe('edge cases', () => {
it('handles empty string', () => {
const result = parseQuery('')
expect(result.text).toBe('')
expect(result.filters).toEqual({})
})
it('handles whitespace only', () => {
const result = parseQuery(' ')
expect(result.text).toBe('')
expect(result.filters).toEqual({})
})
it('ignores empty type value', () => {
const result = parseQuery('type:')
expect(result.text).toBe('')
expect(result.filters).toEqual({})
})
it('ignores empty tag value', () => {
const result = parseQuery('tag:')
expect(result.text).toBe('')
expect(result.filters).toEqual({})
})
it('last duplicate filter wins for type', () => {
const result = parseQuery('type:command type:snippet docker')
expect(result.text).toBe('docker')
expect(result.filters).toEqual({ type: 'snippet' })
})
it('last duplicate filter wins for tag', () => {
const result = parseQuery('tag:python tag:javascript code')
expect(result.text).toBe('code')
expect(result.filters).toEqual({ tag: 'javascript' })
})
})
describe('case sensitivity', () => {
it('filter name is case insensitive', () => {
const result = parseQuery('TYPE:command')
expect(result.filters).toEqual({ type: 'command' })
})
it('filter value is case sensitive', () => {
const result = parseQuery('type:Command')
expect(result.filters).toEqual({ type: 'Command' })
})
it('is:favorite is case insensitive', () => {
const result = parseQuery('IS:FAVORITE docker')
expect(result.filters).toEqual({ isFavorite: true })
})
it('is:pinned is case insensitive', () => {
const result = parseQuery('IS:PINNED')
expect(result.filters).toEqual({ isPinned: true })
})
})
})