feat: MVP-3 Sprint 1 - Usage tracking, smart dashboard, scoring boost
## 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>
This commit is contained in:
246
__tests__/related.test.ts
Normal file
246
__tests__/related.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { getRelatedNotes } from '@/lib/related'
|
||||
|
||||
// Mock prisma and usage before importing related module
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
note: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/usage', () => ({
|
||||
getUsageStats: jest.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getUsageStats } from '@/lib/usage'
|
||||
|
||||
describe('related.ts', () => {
|
||||
const createMockNote = (overrides = {}) => ({
|
||||
id: 'note-1',
|
||||
title: 'Docker Commands',
|
||||
content: 'docker build and docker run commands',
|
||||
type: 'command',
|
||||
isFavorite: false,
|
||||
isPinned: false,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
tags: [
|
||||
{ tag: { id: 'tag-1', name: 'docker' } },
|
||||
{ tag: { id: 'tag-2', name: 'containers' } },
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createMockOtherNote = (overrides = {}) => ({
|
||||
id: 'note-other',
|
||||
title: 'Other Note Title',
|
||||
content: 'other content xyz123',
|
||||
type: 'snippet',
|
||||
tags: [] as { tag: { id: string; name: string } }[],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Default usage stats - no usage
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 0 })
|
||||
})
|
||||
|
||||
describe('getRelatedNotes', () => {
|
||||
it('should return empty array when note not found', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(null)
|
||||
|
||||
const result = await getRelatedNotes('non-existent')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when no related notes found', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-x', title: 'Completely Different', content: 'xyz abc def' }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should return related notes sorted by score', async () => {
|
||||
const mockNote = createMockNote()
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(mockNote)
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker Compose', tags: [{ tag: { id: 't1', name: 'docker' } }] }),
|
||||
createMockOtherNote({ id: 'note-3', title: 'React Hooks', content: 'react hooks' }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
// First result should have highest score
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
expect(result[i].score).toBeGreaterThanOrEqual(result[i + 1].score)
|
||||
}
|
||||
})
|
||||
|
||||
it('should give +3 for same type', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different' }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
expect(note2).toBeDefined()
|
||||
expect(note2!.score).toBe(3) // only type match
|
||||
expect(note2!.reason).toContain('Same type')
|
||||
})
|
||||
|
||||
it('should give +3 per shared tag', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({
|
||||
id: 'note-2',
|
||||
type: 'snippet', // different type to isolate tag scoring
|
||||
title: 'XYZ Title',
|
||||
content: 'different content',
|
||||
tags: [
|
||||
{ tag: { id: 'tag-1', name: 'docker' } },
|
||||
{ tag: { id: 'tag-2', name: 'containers' } },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
expect(note2).toBeDefined()
|
||||
expect(note2!.score).toBe(6) // 2 tags * 3 = 6
|
||||
expect(note2!.reason).toContain('Tags: docker, containers')
|
||||
})
|
||||
|
||||
it('should cap title keyword boost at +3', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote({ title: 'Docker Kubernetes', content: 'different' }))
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({
|
||||
id: 'note-2',
|
||||
type: 'snippet',
|
||||
title: 'Docker Kubernetes Python Ruby', // 4 shared keywords but capped at 3
|
||||
content: 'different',
|
||||
tags: [],
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
expect(note2).toBeDefined()
|
||||
// Type mismatch = 0, Tags = 0, Title keywords capped at +3
|
||||
expect(note2!.score).toBeLessThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('should cap content keyword boost at +2', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote({ title: 'Different', content: 'docker kubernetes python ruby' }))
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({
|
||||
id: 'note-2',
|
||||
type: 'snippet',
|
||||
title: 'Title',
|
||||
content: 'docker kubernetes python', // 3 shared but capped at 2
|
||||
tags: [],
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
expect(note2).toBeDefined()
|
||||
})
|
||||
|
||||
it('should add usage-based boost with view count', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }),
|
||||
])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 15, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
// Base: 3 (type) + floor(15/5)=3 + 2 (recency) = 8
|
||||
expect(note2!.score).toBe(8)
|
||||
})
|
||||
|
||||
it('should cap usage view boost at +3', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }),
|
||||
])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 50, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
// Base: 3 (type) + min(floor(50/5),3)=3 + 2 (recency) = 8
|
||||
expect(note2!.score).toBe(8)
|
||||
})
|
||||
|
||||
it('should add +2 recency boost when note has any views', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }),
|
||||
])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 5, clicks: 0, relatedClicks: 0 })
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
// Base: 3 (type) + floor(5/5)=1 + 2 (recency) = 6
|
||||
expect(note2!.score).toBe(6)
|
||||
})
|
||||
|
||||
it('should use relatedClicks for recency boost', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'XYZ', content: 'different', tags: [] }),
|
||||
])
|
||||
;(getUsageStats as jest.Mock).mockResolvedValue({ views: 0, clicks: 0, relatedClicks: 3 })
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
const note2 = result.find((n) => n.id === 'note-2')
|
||||
// Base: 3 (type) + 0 (views) + 2 (related clicks recency) = 5
|
||||
expect(note2!.score).toBe(5)
|
||||
})
|
||||
|
||||
it('should respect limit parameter', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker', tags: [{ tag: { id: 't1', name: 'docker' } }] }),
|
||||
createMockOtherNote({ id: 'note-3', type: 'command', title: 'Kubernetes', tags: [{ tag: { id: 't2', name: 'kubernetes' } }] }),
|
||||
createMockOtherNote({ id: 'note-4', type: 'command', title: 'Git', tags: [{ tag: { id: 't3', name: 'git' } }] }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1', 2)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should include reason field', async () => {
|
||||
;(prisma.note.findUnique as jest.Mock).mockResolvedValue(createMockNote())
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
createMockOtherNote({ id: 'note-2', type: 'command', title: 'Docker', tags: [{ tag: { id: 't1', name: 'docker' } }] }),
|
||||
])
|
||||
|
||||
const result = await getRelatedNotes('note-1')
|
||||
|
||||
expect(result[0].reason).toBeDefined()
|
||||
expect(typeof result[0].reason).toBe('string')
|
||||
expect(result[0].reason.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user