feat: MVP-3 Sprint 4 - Co-usage, metrics, centrality, creation source, feature flags
- Add NoteCoUsage model and co-usage tracking when viewing notes - Add creationSource field to notes (form/quick/import) - Add dashboard metrics API (/api/metrics) - Add centrality calculation (/api/centrality) - Add feature flags system for toggling features - Add multiline QuickAdd with smart paste type detection - Add internal link suggestions while editing notes - Add type inference for automatic note type detection - Add comprehensive tests for type-inference and link-suggestions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
171
__tests__/link-suggestions.test.ts
Normal file
171
__tests__/link-suggestions.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { findLinkSuggestions, applyWikiLinks } from '@/lib/link-suggestions'
|
||||
|
||||
// Mock prisma
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
note: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
describe('link-suggestions.ts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('findLinkSuggestions', () => {
|
||||
it('returns empty array for short content', async () => {
|
||||
const result = await findLinkSuggestions('Hi')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for empty content', async () => {
|
||||
const result = await findLinkSuggestions('')
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('finds matching note titles in content', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
{ id: '1', title: 'Docker Commands' },
|
||||
{ id: '2', title: 'Git Tutorial' },
|
||||
])
|
||||
|
||||
const content = 'I use Docker Commands for containers and Git Tutorial for version control.'
|
||||
const result = await findLinkSuggestions(content)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map(r => r.noteTitle)).toContain('Docker Commands')
|
||||
expect(result.map(r => r.noteTitle)).toContain('Git Tutorial')
|
||||
})
|
||||
|
||||
it('excludes current note from suggestions', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
{ id: '1', title: 'Current Note' },
|
||||
{ id: '2', title: 'Related Note' },
|
||||
])
|
||||
|
||||
const content = 'See Related Note for details.'
|
||||
const result = await findLinkSuggestions(content, '1')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].noteTitle).toBe('Related Note')
|
||||
})
|
||||
|
||||
it('sorts by title length (longer first)', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
{ id: '1', title: 'Short' },
|
||||
{ id: '2', title: 'Very Long Title' },
|
||||
{ id: '3', title: 'Medium Title' },
|
||||
])
|
||||
|
||||
const content = 'Short and Medium Title and Very Long Title'
|
||||
const result = await findLinkSuggestions(content)
|
||||
|
||||
expect(result[0].noteTitle).toBe('Very Long Title')
|
||||
expect(result[1].noteTitle).toBe('Medium Title')
|
||||
expect(result[2].noteTitle).toBe('Short')
|
||||
})
|
||||
|
||||
it('returns empty when no matches found', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
{ id: '1', title: 'Docker' },
|
||||
{ id: '2', title: 'Git' },
|
||||
])
|
||||
|
||||
const content = 'Python and JavaScript are programming languages.'
|
||||
const result = await findLinkSuggestions(content)
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('handles case-insensitive matching', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
{ id: '1', title: 'Docker Commands' },
|
||||
])
|
||||
|
||||
const content = 'I use DOCKER COMMANDS for my project.'
|
||||
const result = await findLinkSuggestions(content)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].noteTitle).toBe('Docker Commands')
|
||||
})
|
||||
|
||||
it('matches whole words only', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([
|
||||
{ id: '1', title: 'Git' },
|
||||
])
|
||||
|
||||
const content = 'GitHub uses Git internally.'
|
||||
const result = await findLinkSuggestions(content)
|
||||
|
||||
// Should match standalone 'Git' but not 'Git' within 'GitHub'
|
||||
// Note: the regex \bGit\b matches standalone 'Git', not 'Git' in 'GitHub'
|
||||
expect(result.some(r => r.noteTitle === 'Git')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty when no notes exist', async () => {
|
||||
;(prisma.note.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const result = await findLinkSuggestions('Some content with potential matches')
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyWikiLinks', () => {
|
||||
it('replaces terms with wiki-links', () => {
|
||||
const content = 'I use Docker and Git for projects.'
|
||||
const replacements = [
|
||||
{ term: 'Docker', noteId: '1' },
|
||||
{ term: 'Git', noteId: '2' },
|
||||
]
|
||||
|
||||
const result = applyWikiLinks(content, replacements)
|
||||
|
||||
expect(result).toBe('I use [[Docker]] and [[Git]] for projects.')
|
||||
})
|
||||
|
||||
it('handles multiple occurrences', () => {
|
||||
const content = 'Docker is great. Docker is fast.'
|
||||
const replacements = [{ term: 'Docker', noteId: '1' }]
|
||||
|
||||
const result = applyWikiLinks(content, replacements)
|
||||
|
||||
expect(result).toBe('[[Docker]] is great. [[Docker]] is fast.')
|
||||
})
|
||||
|
||||
it('handles case-insensitive matching and replaces with link term', () => {
|
||||
const content = 'DOCKER and docker and Docker'
|
||||
const replacements = [{ term: 'Docker', noteId: '1' }]
|
||||
|
||||
const result = applyWikiLinks(content, replacements)
|
||||
|
||||
// All variations matched and replaced with the link text
|
||||
expect(result).toBe('[[Docker]] and [[Docker]] and [[Docker]]')
|
||||
})
|
||||
|
||||
it('returns original content when no replacements', () => {
|
||||
const content = 'Original content'
|
||||
const replacements: { term: string; noteId: string }[] = []
|
||||
|
||||
const result = applyWikiLinks(content, replacements)
|
||||
|
||||
expect(result).toBe('Original content')
|
||||
})
|
||||
|
||||
it('replaces multiple different terms', () => {
|
||||
const content = 'Use React and TypeScript together.'
|
||||
const replacements = [
|
||||
{ term: 'React', noteId: '1' },
|
||||
{ term: 'TypeScript', noteId: '2' },
|
||||
]
|
||||
|
||||
const result = applyWikiLinks(content, replacements)
|
||||
|
||||
expect(result).toBe('Use [[React]] and [[TypeScript]] together.')
|
||||
})
|
||||
})
|
||||
})
|
||||
221
__tests__/type-inference.test.ts
Normal file
221
__tests__/type-inference.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
|
||||
|
||||
describe('type-inference.ts', () => {
|
||||
describe('inferNoteType', () => {
|
||||
describe('command detection', () => {
|
||||
it('detects git commands', () => {
|
||||
const content = 'git commit -m "fix: resolve issue"\nnpm install\ndocker build'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('command')
|
||||
expect(result?.confidence).toBeTruthy() // any confidence level
|
||||
})
|
||||
|
||||
it('detects docker commands', () => {
|
||||
const content = 'docker build -t myapp .\ndocker run -d'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('command')
|
||||
})
|
||||
|
||||
it('detects shell prompts', () => {
|
||||
const content = '$ curl -X POST https://api.example.com\n$ npm install'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('command')
|
||||
})
|
||||
|
||||
it('detects shebang', () => {
|
||||
const content = '#!/bin/bash\necho "Hello"'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('command')
|
||||
})
|
||||
})
|
||||
|
||||
describe('snippet detection', () => {
|
||||
it('detects code blocks', () => {
|
||||
const content = '```javascript\nconst x = 1;\n```'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('snippet')
|
||||
})
|
||||
|
||||
it('detects function declarations', () => {
|
||||
const content = 'function hello() {\n return "world";\n}'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('snippet')
|
||||
})
|
||||
|
||||
it('detects ES6 imports', () => {
|
||||
const content = 'import React from "react"\nexport default App'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('snippet')
|
||||
})
|
||||
|
||||
it('detects arrow functions', () => {
|
||||
const content = 'const add = (a, b) => a + b;'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('snippet')
|
||||
})
|
||||
|
||||
it('detects object literals', () => {
|
||||
const content = 'const config = {\n name: "app",\n version: "1.0.0"\n}'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('snippet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('procedure detection', () => {
|
||||
it('detects numbered steps', () => {
|
||||
const content = '1. First step\n2. Second step\n3. Third step'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('procedure')
|
||||
})
|
||||
|
||||
it('detects bullet points as steps', () => {
|
||||
const content = '- Open the terminal\n- Run the command\n- Check the output'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('procedure')
|
||||
})
|
||||
|
||||
it('detects step-related keywords', () => {
|
||||
const content = 'primer paso, segundo paso, tercer paso, finally'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('procedure')
|
||||
})
|
||||
|
||||
it('detects tutorial language', () => {
|
||||
const content = 'How to install Node.js:\n1. Download the installer\n2. Run the setup'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('procedure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recipe detection', () => {
|
||||
it('detects ingredients pattern', () => {
|
||||
const content = 'ingredientes:\n- 2 tazas de harina\n- 1 taza de azúcar'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('recipe')
|
||||
})
|
||||
|
||||
it('detects recipe-related keywords', () => {
|
||||
const content = 'receta:\n1. sofreír cebolla\n2. añadir arroz\ntiempo: 30 minutos'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('recipe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decision detection', () => {
|
||||
it('detects decision context', () => {
|
||||
const content = 'Decisión: Usar PostgreSQL en lugar de MySQL\n\nRazón: Mejor soporte para JSON y transacciones.'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('decision')
|
||||
})
|
||||
|
||||
it('detects pros and cons', () => {
|
||||
const content = 'decisión:\nPros: Better performance\nContras: More expensive'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('decision')
|
||||
})
|
||||
|
||||
it('detects alternatives considered', () => {
|
||||
const content = 'Alternativas consideradas:\n1. AWS\n2. GCP\n3. Azure\n\nElegimos Vercel por su integración con Next.js.'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('decision')
|
||||
})
|
||||
})
|
||||
|
||||
describe('inventory detection', () => {
|
||||
it('detects quantity patterns', () => {
|
||||
const content = 'Item: Laptop\nCantidad: 5\nUbicación: Oficina principal'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('inventory')
|
||||
})
|
||||
|
||||
it('detects inventory-related keywords', () => {
|
||||
const content = 'Stock disponible: 100 unidades\nNivel mínimo: 20'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('inventory')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns note type for generic content', () => {
|
||||
const content = 'This is a simple note about my day.'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('note')
|
||||
})
|
||||
|
||||
it('returns note type with low confidence for empty content', () => {
|
||||
const result = inferNoteType('')
|
||||
expect(result?.type).toBe('note')
|
||||
expect(result?.confidence).toBe('low')
|
||||
})
|
||||
|
||||
it('returns note type with low confidence for very short content', () => {
|
||||
const result = inferNoteType('Hi')
|
||||
expect(result?.type).toBe('note')
|
||||
expect(result?.confidence).toBe('low')
|
||||
})
|
||||
|
||||
it('prioritizes highest confidence match', () => {
|
||||
// Command-like code with shell prompt - medium confidence (2 patterns)
|
||||
const content = '$ npm install\ngit commit -m "fix"'
|
||||
const result = inferNoteType(content)
|
||||
expect(result?.type).toBe('command')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatContentForType', () => {
|
||||
it('formats command type', () => {
|
||||
const content = 'git status'
|
||||
const result = formatContentForType(content, 'command')
|
||||
expect(result).toContain('## Comando')
|
||||
expect(result).toContain('## Cuándo usarlo')
|
||||
expect(result).toContain('## Ejemplo')
|
||||
})
|
||||
|
||||
it('formats snippet type', () => {
|
||||
const content = 'const x = 1;'
|
||||
const result = formatContentForType(content, 'snippet')
|
||||
expect(result).toContain('## Snippet')
|
||||
expect(result).toContain('## Lenguaje')
|
||||
expect(result).toContain('## Código')
|
||||
})
|
||||
|
||||
it('formats procedure type', () => {
|
||||
const content = '1. Step one\n2. Step two'
|
||||
const result = formatContentForType(content, 'procedure')
|
||||
expect(result).toContain('## Objetivo')
|
||||
expect(result).toContain('## Pasos')
|
||||
expect(result).toContain('## Requisitos')
|
||||
})
|
||||
|
||||
it('formats recipe type', () => {
|
||||
const content = 'Ingredients:\n- Flour\n- Sugar'
|
||||
const result = formatContentForType(content, 'recipe')
|
||||
expect(result).toContain('## Ingredientes')
|
||||
expect(result).toContain('## Pasos')
|
||||
expect(result).toContain('## Tiempo')
|
||||
})
|
||||
|
||||
it('formats decision type', () => {
|
||||
const content = 'Use TypeScript'
|
||||
const result = formatContentForType(content, 'decision')
|
||||
expect(result).toContain('## Contexto')
|
||||
expect(result).toContain('## Decisión')
|
||||
expect(result).toContain('## Alternativas')
|
||||
})
|
||||
|
||||
it('formats inventory type', () => {
|
||||
const content = 'Laptop model X'
|
||||
const result = formatContentForType(content, 'inventory')
|
||||
expect(result).toContain('## Item')
|
||||
expect(result).toContain('## Cantidad')
|
||||
expect(result).toContain('## Ubicación')
|
||||
})
|
||||
|
||||
it('formats note type', () => {
|
||||
const content = 'Simple note content'
|
||||
const result = formatContentForType(content, 'note')
|
||||
expect(result).toContain('## Notas')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user