Files
recall/__tests__/related.test.ts
Daniel Arroyo 05b8f3910d 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>
2026-03-22 16:03:14 -03:00

247 lines
9.2 KiB
TypeScript

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)
})
})
})