## Registro de Uso - Nuevo modelo NoteUsage en Prisma - Tipos de eventos: view, search_click, related_click, link_click, copy_command, copy_snippet - Funciones: trackNoteUsage, getUsageStats, getRecentlyUsedNotes - localStorage: recentlyViewed (últimas 10 notas) - Rastreo de copias en markdown-content.tsx ## Dashboard Rediseñado - 5 bloques: Recientes, Más usadas, Comandos recientes, Snippets recientes, Según actividad - Nuevo src/lib/dashboard.ts con getDashboardData() - Recomendaciones basadas en recentlyViewed ## Scoring con Uso Real - search.ts: +1 per 5 views (max +3), +2 recency boost - related.ts: mismo sistema de usage boost - No eclipsa match textual fuerte ## Tests - 110 tests pasando (usage, dashboard, related, search) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import { highlightMatches, noteQuery, searchNotes, ScoredNote } from '@/lib/search'
|
|
|
|
// Mock prisma and usage before importing search module
|
|
jest.mock('@/lib/prisma', () => ({
|
|
prisma: {
|
|
note: {
|
|
findMany: jest.fn(),
|
|
},
|
|
},
|
|
}))
|
|
|
|
jest.mock('@/lib/usage', () => ({
|
|
getUsageStats: jest.fn(),
|
|
}))
|
|
|
|
import { prisma } from '@/lib/prisma'
|
|
import { getUsageStats } from '@/lib/usage'
|
|
|
|
describe('search.ts', () => {
|
|
const mockNotes = [
|
|
{
|
|
id: 'note-1',
|
|
title: 'Docker Commands',
|
|
content: 'docker build and docker run',
|
|
type: 'command',
|
|
isFavorite: true,
|
|
isPinned: false,
|
|
createdAt: new Date('2024-01-01'),
|
|
updatedAt: new Date('2024-01-15'),
|
|
tags: [{ tag: { id: 'tag-1', name: 'docker' } }],
|
|
},
|
|
{
|
|
id: 'note-2',
|
|
title: 'React Hooks',
|
|
content: 'useState and useEffect hooks',
|
|
type: 'snippet',
|
|
isFavorite: false,
|
|
isPinned: true,
|
|
createdAt: new Date('2024-01-02'),
|
|
updatedAt: new Date('2024-01-10'),
|
|
tags: [{ tag: { id: 'tag-2', name: 'react' } }],
|
|
},
|
|
{
|
|
id: 'note-3',
|
|
title: 'Git Commands',
|
|
content: 'git commit and git push',
|
|
type: 'command',
|
|
isFavorite: false,
|
|
isPinned: false,
|
|
createdAt: new Date('2024-01-03'),
|
|
updatedAt: new Date('2024-01-05'),
|
|
tags: [{ tag: { id: 'tag-3', name: 'git' } }],
|
|
},
|
|
]
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
// Default: no usage
|
|
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
|
})
|
|
|
|
describe('highlightMatches', () => {
|
|
it('returns first 150 characters when query is empty', () => {
|
|
const text = 'This is a long text that should be truncated to 150 characters. ' +
|
|
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor.'
|
|
const result = highlightMatches(text, '')
|
|
expect(result.length).toBeLessThanOrEqual(150)
|
|
})
|
|
|
|
it('finds and highlights matching words', () => {
|
|
const text = 'This is a test document about JavaScript programming.'
|
|
const result = highlightMatches(text, 'JavaScript')
|
|
expect(result).toContain('<mark>JavaScript</mark>')
|
|
})
|
|
|
|
it('handles multiple word queries', () => {
|
|
const text = 'React is a JavaScript library for building user interfaces.'
|
|
const result = highlightMatches(text, 'JavaScript React')
|
|
expect(result).toContain('<mark>JavaScript</mark>')
|
|
expect(result).toContain('<mark>React</mark>')
|
|
})
|
|
|
|
it('escapes regex special characters in query', () => {
|
|
const text = 'What is $100 + $200?'
|
|
const result = highlightMatches(text, '$100')
|
|
expect(result).toContain('<mark>$100</mark>')
|
|
})
|
|
|
|
it('adds ellipsis when match is not at start', () => {
|
|
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. JavaScript is great.'
|
|
const result = highlightMatches(text, 'JavaScript')
|
|
expect(result).toContain('<mark>JavaScript</mark>')
|
|
})
|
|
|
|
it('returns plain text when no match found', () => {
|
|
const text = 'This is a simple text without the word we are looking for.'
|
|
const result = highlightMatches(text, 'xyz123')
|
|
expect(result).not.toContain('<mark>')
|
|
})
|
|
|
|
it('filters out single character words and returns excerpt', () => {
|
|
const text = 'A B C D E F G H I J K L M N O P Q R S T U V W X Y Z'
|
|
const result = highlightMatches(text, 'A B C')
|
|
// Single char words (A, B, C) are filtered, returns 150 chars
|
|
expect(result.length).toBeLessThanOrEqual(150)
|
|
})
|
|
|
|
it('adds ellipsis when match is far from start', () => {
|
|
// JavaScript is at position ~26, start = max(0, 26-75) = 0, so no ellipsis needed
|
|
// We need a longer text where match is more than 75 chars from start
|
|
const text = 'Lorem ipsum dolor sit amet. '.repeat(10) + 'JavaScript programming language.'
|
|
const result = highlightMatches(text, 'JavaScript')
|
|
expect(result).toContain('...')
|
|
})
|
|
})
|
|
|
|
describe('noteQuery', () => {
|
|
it('returns empty array when no notes exist', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
|
|
|
const result = await noteQuery('docker')
|
|
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('returns notes with exact title match scored highest', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('docker')
|
|
|
|
expect(result[0].id).toBe('note-1') // exact title match
|
|
expect(result[0].matchType).toBe('exact')
|
|
})
|
|
|
|
it('returns notes with content match', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('hooks')
|
|
|
|
expect(result.length).toBeGreaterThan(0)
|
|
expect(result.some((n: ScoredNote) => n.id === 'note-2')).toBe(true)
|
|
})
|
|
|
|
it('returns fuzzy matches when no exact match', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('docer') // typo
|
|
|
|
expect(result.length).toBeGreaterThan(0)
|
|
expect(result[0].matchType).toBe('fuzzy')
|
|
})
|
|
|
|
it('excludes notes with no match (low similarity)', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('xyz123nonexistent')
|
|
|
|
expect(result.length).toBe(0)
|
|
})
|
|
|
|
it('adds +2 for favorite notes', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('docker')
|
|
|
|
// note-1 is favorite, should have higher score
|
|
const dockerNote = result.find((n: ScoredNote) => n.id === 'note-1')
|
|
expect(dockerNote?.isFavorite).toBe(true)
|
|
})
|
|
|
|
it('adds +1 for pinned notes', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('hooks')
|
|
|
|
// note-2 is pinned
|
|
const hooksNote = result.find((n: ScoredNote) => n.id === 'note-2')
|
|
expect(hooksNote?.isPinned).toBe(true)
|
|
})
|
|
|
|
it('adds +1 for recently updated notes (within 7 days)', async () => {
|
|
const recentNote = {
|
|
...mockNotes[0],
|
|
updatedAt: new Date(Date.now() - 1000), // just now
|
|
}
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([recentNote])
|
|
|
|
const result = await noteQuery('docker')
|
|
|
|
expect(result[0].score).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('filters by type when specified', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('', { type: 'command' })
|
|
|
|
expect(result.every((n: ScoredNote) => n.type === 'command')).toBe(true)
|
|
})
|
|
|
|
it('filters by tag when specified', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('', { tag: 'docker' })
|
|
|
|
expect(result.length).toBe(1)
|
|
expect(result[0].id).toBe('note-1')
|
|
})
|
|
|
|
it('returns notes sorted by score descending', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('')
|
|
|
|
for (let i = 0; i < result.length - 1; i++) {
|
|
expect(result[i].score).toBeGreaterThanOrEqual(result[i + 1].score)
|
|
}
|
|
})
|
|
|
|
it('returns highlight excerpt for matched notes', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await noteQuery('docker')
|
|
|
|
expect(result[0].highlight).toBeDefined()
|
|
expect(typeof result[0].highlight).toBe('string')
|
|
})
|
|
})
|
|
|
|
describe('searchNotes', () => {
|
|
it('should be an alias for noteQuery', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await searchNotes('docker')
|
|
|
|
expect(result).toEqual(await noteQuery('docker'))
|
|
})
|
|
|
|
it('passes filters to noteQuery', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue(mockNotes)
|
|
|
|
const result = await searchNotes('', { type: 'snippet' })
|
|
|
|
expect(result.every((n: ScoredNote) => n.type === 'snippet')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('usage-based scoring boost', () => {
|
|
it('calls getUsageStats for each note', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
|
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
|
|
|
await noteQuery('docker')
|
|
|
|
expect(getUsageStats).toHaveBeenCalledWith('note-1', 7)
|
|
})
|
|
|
|
it('handles getUsageStats returning zero values', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
|
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
|
|
|
const result = await noteQuery('docker')
|
|
|
|
// Should return results without error
|
|
expect(result).toBeDefined()
|
|
expect(result.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('ScoredNote interface', () => {
|
|
it('returns correct structure for scored notes', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
|
|
|
const result = await noteQuery('docker')
|
|
|
|
expect(result[0]).toHaveProperty('id')
|
|
expect(result[0]).toHaveProperty('title')
|
|
expect(result[0]).toHaveProperty('content')
|
|
expect(result[0]).toHaveProperty('type')
|
|
expect(result[0]).toHaveProperty('isFavorite')
|
|
expect(result[0]).toHaveProperty('isPinned')
|
|
expect(result[0]).toHaveProperty('createdAt')
|
|
expect(result[0]).toHaveProperty('updatedAt')
|
|
expect(result[0]).toHaveProperty('tags')
|
|
expect(result[0]).toHaveProperty('score')
|
|
expect(result[0]).toHaveProperty('highlight')
|
|
expect(result[0]).toHaveProperty('matchType')
|
|
expect(['exact', 'fuzzy']).toContain(result[0].matchType)
|
|
})
|
|
|
|
it('converts Date objects to ISO strings', async () => {
|
|
;(prisma.note.findMany as jest.Mock).mockResolvedValue([mockNotes[0]])
|
|
|
|
const result = await noteQuery('docker')
|
|
|
|
expect(result[0].createdAt).toBe('2024-01-01T00:00:00.000Z')
|
|
expect(result[0].updatedAt).toBe('2024-01-15T00:00:00.000Z')
|
|
})
|
|
})
|
|
})
|