fix: improve note interactions and markdown LaTeX support
## Bug Fixes ### Note Card Actions - Fix broken size change functionality (missing state declaration) - Implement React 19 useOptimistic for instant UI feedback - Add startTransition for non-blocking updates - Ensure smooth animations without page refresh - All note actions now work: pin, archive, color, size, checklist ### Markdown LaTeX Rendering - Add remark-math and rehype-katex plugins - Support inline equations with dollar sign syntax - Support block equations with double dollar sign syntax - Import KaTeX CSS for proper styling - Equations now render correctly instead of showing raw LaTeX ## Technical Details - Replace undefined currentNote references with optimistic state - Add optimistic updates before server actions for instant feedback - Use router.refresh() in transitions for smart cache invalidation - Install remark-math, rehype-katex, and katex packages ## Testing - Build passes successfully with no TypeScript errors - Dev server hot-reloads changes correctly
This commit is contained in:
132
keep-notes/tests/collaboration.spec.ts
Normal file
132
keep-notes/tests/collaboration.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Collaboration Feature', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login before each test
|
||||
await page.goto('http://localhost:3000/login');
|
||||
await page.fill('input[name="email"]', 'test@example.com');
|
||||
await page.fill('input[name="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL('http://localhost:3000/');
|
||||
});
|
||||
|
||||
test('COLLAB-2: Add collaborator to existing note', async ({ page, context }) => {
|
||||
// Start from the main page
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
// Find a note card (should have at least one)
|
||||
const noteCard = page.locator('[data-testid="note-card"]').first();
|
||||
await expect(noteCard).toBeVisible();
|
||||
|
||||
// Open collaborator dialog from the note menu
|
||||
await noteCard.hover();
|
||||
await page.click('[aria-label="More options"]');
|
||||
|
||||
// Click "Share with collaborators"
|
||||
await page.click('text=Share with collaborators');
|
||||
|
||||
// Verify dialog opens
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator('text=Share with collaborators')).toBeVisible();
|
||||
|
||||
// Try to add a collaborator (note: user may not exist)
|
||||
await page.fill('input[type="email"]', 'collaborator@example.com');
|
||||
await page.click('button:has-text("Invite")');
|
||||
|
||||
// Wait for response (either success or error toast)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Verify either:
|
||||
// 1. Success: User added to list
|
||||
// 2. Error: User not found message
|
||||
const collaboratorInList = page.locator('text=collaborator@example.com');
|
||||
const errorMessage = page.locator('.toast:has-text("User not found")');
|
||||
|
||||
const collaboratorAdded = await collaboratorInList.count() > 0;
|
||||
const errorShown = await errorMessage.count() > 0;
|
||||
|
||||
expect(collaboratorAdded || errorShown).toBeTruthy();
|
||||
});
|
||||
|
||||
test('COLLAB-2: Remove collaborator from note', async ({ page }) => {
|
||||
// This test assumes a note with collaborators exists
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
// Find a note card
|
||||
const noteCard = page.locator('[data-testid="note-card"]').first();
|
||||
await noteCard.hover();
|
||||
await page.click('[aria-label="More options"]');
|
||||
await page.click('text=Share with collaborators');
|
||||
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Check if there are any collaborators listed
|
||||
const collaboratorItems = dialog.locator('[data-testid="collaborator-item"]');
|
||||
const count = await collaboratorItems.count();
|
||||
|
||||
if (count > 0) {
|
||||
// Get initial count
|
||||
const initialCount = count;
|
||||
|
||||
// Remove first collaborator
|
||||
await collaboratorItems.first().hover();
|
||||
await collaboratorItems.first().locator('button[aria-label="Remove"]').click();
|
||||
|
||||
// Wait for update
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify count decreased
|
||||
const newCount = await dialog.locator('[data-testid="collaborator-item"]').count();
|
||||
expect(newCount).toBeLessThan(initialCount);
|
||||
} else {
|
||||
// Skip test if no collaborators exist
|
||||
test.skip(true, 'No collaborators to remove');
|
||||
}
|
||||
});
|
||||
|
||||
test('COLLAB-2: View collaborators list', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
const noteCard = page.locator('[data-testid="note-card"]').first();
|
||||
await noteCard.hover();
|
||||
await page.click('[aria-label="More options"]');
|
||||
await page.click('text=Share with collaborators');
|
||||
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Verify "People with access" section exists
|
||||
await expect(dialog.locator('text=People with access')).toBeVisible();
|
||||
|
||||
// Check for owner badge
|
||||
const ownerBadge = dialog.locator('text=Owner');
|
||||
await expect(ownerBadge).toBeVisible();
|
||||
});
|
||||
|
||||
test('COLLAB-2: Non-owner cannot remove collaborators', async ({ page }) => {
|
||||
// This test requires setup with a shared note where current user is not owner
|
||||
// For now, we'll test the UI shows correct state
|
||||
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
const noteCard = page.locator('[data-testid="note-card"]').first();
|
||||
await noteCard.hover();
|
||||
await page.click('[aria-label="More options"]');
|
||||
await page.click('text=Share with collaborators');
|
||||
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Verify "Only the owner can manage collaborators" message appears if not owner
|
||||
const description = dialog.locator('text=You have access to this note');
|
||||
const isNotOwner = await description.count() > 0;
|
||||
|
||||
if (isNotOwner) {
|
||||
// Verify no remove buttons are visible
|
||||
const removeButtons = dialog.locator('button[aria-label="Remove"]');
|
||||
expect(await removeButtons.count()).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
63
keep-notes/tests/search-quality.spec.ts
Normal file
63
keep-notes/tests/search-quality.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Search Quality Tests', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 1. Go to Home
|
||||
await page.goto('/');
|
||||
|
||||
// Handle Login if redirected (Mock behavior or real login)
|
||||
if (page.url().includes('login')) {
|
||||
// Assuming a simple dev login or we need to bypass.
|
||||
// For this environment, let's try to just continue or fail explicitly if stuck.
|
||||
console.log('Redirected to login. Please ensure you are logged in for tests.');
|
||||
}
|
||||
});
|
||||
|
||||
test('should find notes by semantic concept', async ({ page }) => {
|
||||
// Clean up existing notes to avoid noise? (Optional, better to just create unique ones)
|
||||
const timestamp = Date.now();
|
||||
const techTitle = `React Tech ${timestamp}`;
|
||||
const foodTitle = `Italian Food ${timestamp}`;
|
||||
|
||||
// 2. Create Tech Note
|
||||
await page.getByPlaceholder('Take a note...').click();
|
||||
await page.getByPlaceholder('Title').fill(techTitle);
|
||||
await page.getByPlaceholder('Take a note...').fill('Hooks, useEffect, State management, Components logic.');
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(page.getByText(techTitle)).toBeVisible();
|
||||
|
||||
// 3. Create Food Note
|
||||
await page.getByPlaceholder('Take a note...').click();
|
||||
await page.getByPlaceholder('Title').fill(foodTitle);
|
||||
await page.getByPlaceholder('Take a note...').fill('Tomato sauce, Basil, Parmesan cheese, boiling water.');
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(page.getByText(foodTitle)).toBeVisible();
|
||||
|
||||
// Wait a bit for potential async indexing (even if server action is awaited, good practice)
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 4. Search for "Coding" (Semantic match for React)
|
||||
const searchInput = page.getByPlaceholder('Search');
|
||||
await searchInput.fill('Coding');
|
||||
await searchInput.press('Enter');
|
||||
|
||||
// Allow time for search results
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Assertions
|
||||
// Tech note should be visible
|
||||
await expect(page.getByText(techTitle)).toBeVisible();
|
||||
// Food note should NOT be visible (or at least ranked lower/hidden if filtering is strict)
|
||||
await expect(page.getByText(foodTitle)).toBeHidden();
|
||||
|
||||
// 5. Search for "Cooking" (Semantic match for Food)
|
||||
await searchInput.fill('');
|
||||
await searchInput.fill('Cooking dinner');
|
||||
await searchInput.press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(page.getByText(foodTitle)).toBeVisible();
|
||||
await expect(page.getByText(techTitle)).toBeHidden();
|
||||
});
|
||||
});
|
||||
143
keep-notes/tests/search/non-regression.spec.ts
Normal file
143
keep-notes/tests/search/non-regression.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Non-regression tests for semantic search
|
||||
* These tests ensure that increasing the threshold from 0.40 to 0.65
|
||||
* doesn't lose valid positive matches
|
||||
*/
|
||||
test.describe('Search Non-Regression Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('should find notes with exact keyword matches', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
await searchInput.fill('test');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const results = await page.locator('.note-card').count();
|
||||
|
||||
// Even with higher semantic threshold, exact keyword matches should always appear
|
||||
if (results > 0) {
|
||||
const titles = await page.locator('.note-card .note-title').allTextContents();
|
||||
const hasExactMatch = titles.some(title =>
|
||||
title.toLowerCase().includes('test')
|
||||
);
|
||||
expect(hasExactMatch).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should find notes with title matches', async ({ page }) => {
|
||||
// Search for something that should be in titles
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Try a generic search term
|
||||
await searchInput.fill('note');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const results = await page.locator('.note-card');
|
||||
const count = await results.count();
|
||||
|
||||
// Should find results with "note" in them
|
||||
if (count > 0) {
|
||||
const allText = await results.allTextContents();
|
||||
const hasNote = allText.some(text =>
|
||||
text.toLowerCase().includes('note')
|
||||
);
|
||||
expect(hasNote).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle multi-word queries correctly', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Multi-word search should work
|
||||
await searchInput.fill('test note');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should not error and should return results
|
||||
const results = await page.locator('.note-card').count();
|
||||
expect(results).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('should be case-insensitive', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Search with uppercase
|
||||
await searchInput.fill('TEST');
|
||||
await page.waitForTimeout(500);
|
||||
const upperCount = await page.locator('.note-card').count();
|
||||
|
||||
// Search with lowercase
|
||||
await searchInput.fill('test');
|
||||
await page.waitForTimeout(500);
|
||||
const lowerCount = await page.locator('.note-card').count();
|
||||
|
||||
// Should return same results
|
||||
expect(upperCount).toBe(lowerCount);
|
||||
});
|
||||
|
||||
test('should handle empty search gracefully', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Empty search should show all notes or handle gracefully
|
||||
await searchInput.fill('');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should not error
|
||||
const results = await page.locator('.note-card').count();
|
||||
expect(results).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('should clear search results', async ({ page }) => {
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
|
||||
// Search for something
|
||||
await searchInput.fill('temporary search');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Clear search
|
||||
await searchInput.fill('');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should return to showing all notes (or initial state)
|
||||
const results = await page.locator('.note-card').count();
|
||||
expect(results).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for threshold behavior
|
||||
*/
|
||||
test.describe('Threshold Behavior Unit Tests', () => {
|
||||
test('threshold 0.65 should filter more than 0.40', () => {
|
||||
// Simulate semantic scores
|
||||
const scores = [0.80, 0.65, 0.50, 0.45, 0.40, 0.30, 0.20];
|
||||
|
||||
// Old threshold (0.40)
|
||||
const oldThresholdMatches = scores.filter(s => s > 0.40).length;
|
||||
|
||||
// New threshold (0.65)
|
||||
const newThresholdMatches = scores.filter(s => s > 0.65).length;
|
||||
|
||||
// New threshold should filter more strictly
|
||||
expect(newThresholdMatches).toBeLessThan(oldThresholdMatches);
|
||||
});
|
||||
|
||||
test('threshold 0.65 should keep high-quality matches', () => {
|
||||
const highQualityScores = [0.95, 0.85, 0.75, 0.70, 0.68];
|
||||
|
||||
// All high-quality scores should pass the threshold
|
||||
const matches = highQualityScores.filter(s => s > 0.65);
|
||||
expect(matches.length).toBe(highQualityScores.length);
|
||||
});
|
||||
|
||||
test('threshold 0.65 should filter low-quality matches', () => {
|
||||
const lowQualityScores = [0.30, 0.40, 0.50, 0.60, 0.64];
|
||||
|
||||
// None should pass the 0.65 threshold
|
||||
const matches = lowQualityScores.filter(s => s > 0.65);
|
||||
expect(matches.length).toBe(0);
|
||||
});
|
||||
});
|
||||
125
keep-notes/tests/search/semantic-threshold.spec.ts
Normal file
125
keep-notes/tests/search/semantic-threshold.spec.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Semantic Search Threshold Tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login before each test
|
||||
await page.goto('http://localhost:3000');
|
||||
// Wait for potential redirect or login
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('should use default threshold of 0.65 for semantic matching', async ({ page }) => {
|
||||
// This test verifies that the default threshold is applied
|
||||
// We'll search for something and check that only relevant results appear
|
||||
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Get initial note count
|
||||
const initialNotes = await page.locator('.note-card').count();
|
||||
|
||||
// Search for something specific
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
await searchInput.fill('programming code');
|
||||
await page.waitForTimeout(500); // Wait for search debounce
|
||||
|
||||
// Get search results
|
||||
const searchResults = await page.locator('.note-card').count();
|
||||
|
||||
// With threshold 0.65, we should have fewer false positives
|
||||
// This is a basic sanity check - exact assertions depend on your data
|
||||
expect(searchResults).toBeLessThanOrEqual(initialNotes);
|
||||
|
||||
// Clear search
|
||||
await searchInput.fill('');
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('should not show unrelated notes when searching for specific terms', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Search for technical terms
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
await searchInput.fill('javascript programming');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get all search result titles
|
||||
const resultTitles = await page.locator('.note-card .note-title').allTextContents();
|
||||
|
||||
// Check that results are related to the search (basic keyword check)
|
||||
// With higher threshold (0.65), semantic matches should be more relevant
|
||||
const hasIrrelevantResults = resultTitles.some(title => {
|
||||
const lowerTitle = title.toLowerCase();
|
||||
// If a result mentions completely unrelated topics like cooking/food
|
||||
// when searching for programming, that's a false positive
|
||||
return lowerTitle.includes('recipe') ||
|
||||
lowerTitle.includes('cooking') ||
|
||||
lowerTitle.includes('food') ||
|
||||
lowerTitle.includes('shopping');
|
||||
});
|
||||
|
||||
// With threshold 0.65, we expect fewer false positives
|
||||
// This test may need adjustment based on your actual data
|
||||
expect(hasIrrelevantResults).toBe(false);
|
||||
});
|
||||
|
||||
test('should still show highly relevant semantic matches', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Search for a concept and its close synonyms
|
||||
const searchInput = page.locator('input[placeholder*="Search"]');
|
||||
await searchInput.fill('coding');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get search results
|
||||
const searchResults = await page.locator('.note-card');
|
||||
const count = await searchResults.count();
|
||||
|
||||
// Even with higher threshold, we should find semantic matches
|
||||
// for closely related terms like "programming", "development", etc.
|
||||
if (count > 0) {
|
||||
const titles = await page.locator('.note-card .note-title').allTextContents();
|
||||
const hasRelevantContent = titles.some(title => {
|
||||
const lowerTitle = title.toLowerCase();
|
||||
return lowerTitle.includes('code') ||
|
||||
lowerTitle.includes('program') ||
|
||||
lowerTitle.includes('develop') ||
|
||||
lowerTitle.includes('software') ||
|
||||
lowerTitle.includes('app');
|
||||
});
|
||||
|
||||
// Should find semantically related content
|
||||
expect(hasRelevantContent).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('threshold can be configured via SystemConfig', async ({ page }) => {
|
||||
// This is an integration test verifying the configuration system
|
||||
// In a real scenario, you'd make an API call to update the config
|
||||
|
||||
// Default threshold should be 0.65
|
||||
const response = await page.request.get('/api/admin/embeddings/validate');
|
||||
|
||||
// This endpoint requires admin auth, so we're just checking it exists
|
||||
// The actual threshold value is tested in unit tests
|
||||
expect(response.ok()).toBeFalsy(); // Should be 401/403 without auth
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Semantic Threshold Unit Tests', () => {
|
||||
test('default threshold should be 0.65', () => {
|
||||
// This is a compile-time check
|
||||
// The actual value is in SEARCH_DEFAULTS.SEMANTIC_THRESHOLD
|
||||
const { SEARCH_DEFAULTS } = require('../../lib/config');
|
||||
expect(SEARCH_DEFAULTS.SEMANTIC_THRESHOLD).toBe(0.65);
|
||||
});
|
||||
|
||||
test('threshold should be higher than old value', () => {
|
||||
const { SEARCH_DEFAULTS } = require('../../lib/config');
|
||||
expect(SEARCH_DEFAULTS.SEMANTIC_THRESHOLD).toBeGreaterThan(0.40);
|
||||
});
|
||||
|
||||
test('threshold should be less than 1.0', () => {
|
||||
const { SEARCH_DEFAULTS } = require('../../lib/config');
|
||||
expect(SEARCH_DEFAULTS.SEMANTIC_THRESHOLD).toBeLessThan(1.0);
|
||||
});
|
||||
});
|
||||
192
keep-notes/tests/unit/adaptive-weighting.test.ts
Normal file
192
keep-notes/tests/unit/adaptive-weighting.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { detectQueryType, getSearchWeights } from '../../lib/utils';
|
||||
import { QueryType } from '../../lib/types';
|
||||
|
||||
test.describe('Query Type Detection Tests', () => {
|
||||
test('should detect exact queries with quotes', () => {
|
||||
expect(detectQueryType('"Error 404"')).toBe('exact');
|
||||
expect(detectQueryType("'exact phrase'")).toBe('exact');
|
||||
expect(detectQueryType('"multiple words"')).toBe('exact');
|
||||
});
|
||||
|
||||
test('should detect conceptual queries', () => {
|
||||
// Question words
|
||||
expect(detectQueryType('how to cook pasta')).toBe('conceptual');
|
||||
expect(detectQueryType('what is python')).toBe('conceptual');
|
||||
expect(detectQueryType('where to find')).toBe('conceptual');
|
||||
expect(detectQueryType('why does this happen')).toBe('conceptual');
|
||||
expect(detectQueryType('who invented')).toBe('conceptual');
|
||||
|
||||
// Conceptual phrases
|
||||
expect(detectQueryType('ways to improve')).toBe('conceptual');
|
||||
expect(detectQueryType('best way to learn')).toBe('conceptual');
|
||||
expect(detectQueryType('guide for beginners')).toBe('conceptual');
|
||||
expect(detectQueryType('tips for cooking')).toBe('conceptual');
|
||||
expect(detectQueryType('learn about javascript')).toBe('conceptual');
|
||||
expect(detectQueryType('understand recursion')).toBe('conceptual');
|
||||
|
||||
// Learning patterns
|
||||
expect(detectQueryType('tutorial on react')).toBe('conceptual');
|
||||
expect(detectQueryType('guide to typescript')).toBe('conceptual');
|
||||
expect(detectQueryType('introduction to python')).toBe('conceptual');
|
||||
expect(detectQueryType('overview of microservices')).toBe('conceptual');
|
||||
expect(detectQueryType('explanation of quantum computing')).toBe('conceptual');
|
||||
expect(detectQueryType('examples of callbacks')).toBe('conceptual');
|
||||
});
|
||||
|
||||
test('should detect mixed queries', () => {
|
||||
// Simple terms
|
||||
expect(detectQueryType('javascript')).toBe('mixed');
|
||||
expect(detectQueryType('programming')).toBe('mixed');
|
||||
expect(detectQueryType('cooking')).toBe('mixed');
|
||||
|
||||
// Multiple words without patterns
|
||||
expect(detectQueryType('javascript programming')).toBe('mixed');
|
||||
expect(detectQueryType('react components')).toBe('mixed');
|
||||
expect(detectQueryType('database design')).toBe('mixed');
|
||||
});
|
||||
|
||||
test('should be case-insensitive', () => {
|
||||
expect(detectQueryType('How To Cook')).toBe('conceptual');
|
||||
expect(detectQueryType('WHAT IS PYTHON')).toBe('conceptual');
|
||||
expect(detectQueryType('"Error 404"')).toBe('exact');
|
||||
});
|
||||
|
||||
test('should handle empty and whitespace queries', () => {
|
||||
expect(detectQueryType('')).toBe('mixed');
|
||||
expect(detectQueryType(' ')).toBe('mixed');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Search Weight Calculation Tests', () => {
|
||||
test('should return correct weights for exact queries', () => {
|
||||
const weights = getSearchWeights('exact');
|
||||
|
||||
expect(weights.keywordWeight).toBe(2.0);
|
||||
expect(weights.semanticWeight).toBe(0.7);
|
||||
});
|
||||
|
||||
test('should return correct weights for conceptual queries', () => {
|
||||
const weights = getSearchWeights('conceptual');
|
||||
|
||||
expect(weights.keywordWeight).toBe(0.7);
|
||||
expect(weights.semanticWeight).toBe(1.5);
|
||||
});
|
||||
|
||||
test('should return correct weights for mixed queries', () => {
|
||||
const weights = getSearchWeights('mixed');
|
||||
|
||||
expect(weights.keywordWeight).toBe(1.0);
|
||||
expect(weights.semanticWeight).toBe(1.0);
|
||||
});
|
||||
|
||||
test('should handle unknown query type as mixed', () => {
|
||||
const weights = getSearchWeights('unknown' as QueryType);
|
||||
|
||||
expect(weights.keywordWeight).toBe(1.0);
|
||||
expect(weights.semanticWeight).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Adaptive Weighting Integration Tests', () => {
|
||||
test('exact query boosts keyword matches', () => {
|
||||
const queryType = detectQueryType('"exact phrase"');
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Keyword matches should be 2x more important
|
||||
expect(weights.keywordWeight).toBeGreaterThan(weights.semanticWeight);
|
||||
expect(weights.keywordWeight / weights.semanticWeight).toBeCloseTo(2.0 / 0.7, 1);
|
||||
});
|
||||
|
||||
test('conceptual query boosts semantic matches', () => {
|
||||
const queryType = detectQueryType('how to cook');
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Semantic matches should be more important
|
||||
expect(weights.semanticWeight).toBeGreaterThan(weights.keywordWeight);
|
||||
expect(weights.semanticWeight / weights.keywordWeight).toBeCloseTo(1.5 / 0.7, 1);
|
||||
});
|
||||
|
||||
test('mixed query treats both equally', () => {
|
||||
const queryType = detectQueryType('javascript programming');
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Both should have equal weight
|
||||
expect(weights.keywordWeight).toBe(weights.semanticWeight);
|
||||
expect(weights.keywordWeight).toBe(1.0);
|
||||
});
|
||||
|
||||
test('weights significantly affect ranking scores', () => {
|
||||
const k = 20;
|
||||
const rank = 5;
|
||||
|
||||
// Same rank with different weights
|
||||
const exactQueryScore = (1 / (k + rank)) * 2.0; // keyword
|
||||
const conceptualQueryScore = (1 / (k + rank)) * 1.5; // semantic
|
||||
|
||||
// Exact keyword match should get highest score
|
||||
expect(exactQueryScore).toBeGreaterThan(conceptualQueryScore);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Weight Impact on Scenarios', () => {
|
||||
test('scenario 1: User searches for "Error 404"', () => {
|
||||
const query = '"Error 404"';
|
||||
const queryType = detectQueryType(query);
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Should be exact match type
|
||||
expect(queryType).toBe('exact');
|
||||
|
||||
// Keyword matches should be heavily prioritized
|
||||
expect(weights.keywordWeight).toBe(2.0);
|
||||
|
||||
// Semantic matches should be deprioritized
|
||||
expect(weights.semanticWeight).toBe(0.7);
|
||||
|
||||
// This ensures that notes with "Error 404" appear first,
|
||||
// even if semantic search might suggest other error types
|
||||
});
|
||||
|
||||
test('scenario 2: User searches for "how to cook pasta"', () => {
|
||||
const query = 'how to cook pasta';
|
||||
const queryType = detectQueryType(query);
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Should be conceptual type
|
||||
expect(queryType).toBe('conceptual');
|
||||
|
||||
// Semantic matches should be boosted
|
||||
expect(weights.semanticWeight).toBe(1.5);
|
||||
|
||||
// Keyword matches should be reduced
|
||||
expect(weights.keywordWeight).toBe(0.7);
|
||||
|
||||
// This ensures that notes about cooking, pasta, recipes appear,
|
||||
// even if they don't contain the exact words "how to cook pasta"
|
||||
});
|
||||
|
||||
test('scenario 3: User searches for "tutorial javascript"', () => {
|
||||
const query = 'tutorial javascript';
|
||||
const queryType = detectQueryType(query);
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Should be conceptual type (starts with "tutorial")
|
||||
expect(queryType).toBe('conceptual');
|
||||
|
||||
// Semantic search should be prioritized
|
||||
expect(weights.semanticWeight).toBeGreaterThan(weights.keywordWeight);
|
||||
});
|
||||
|
||||
test('scenario 4: User searches for "react hooks"', () => {
|
||||
const query = 'react hooks';
|
||||
const queryType = detectQueryType(query);
|
||||
const weights = getSearchWeights(queryType);
|
||||
|
||||
// Should be mixed type (no specific pattern)
|
||||
expect(queryType).toBe('mixed');
|
||||
|
||||
// Both should have equal weight
|
||||
expect(weights.keywordWeight).toBe(weights.semanticWeight);
|
||||
});
|
||||
});
|
||||
136
keep-notes/tests/unit/embedding-validation.test.ts
Normal file
136
keep-notes/tests/unit/embedding-validation.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { validateEmbedding, calculateL2Norm, normalizeEmbedding } from '../../lib/utils'
|
||||
|
||||
test.describe('Embedding Validation', () => {
|
||||
test.describe('validateEmbedding()', () => {
|
||||
test('should validate a normal embedding', () => {
|
||||
const embedding = [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.issues).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should reject empty embedding', () => {
|
||||
const result = validateEmbedding([])
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding is empty or has zero dimensionality')
|
||||
})
|
||||
|
||||
test('should reject null embedding', () => {
|
||||
const result = validateEmbedding(null as any)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding is empty or has zero dimensionality')
|
||||
})
|
||||
|
||||
test('should reject embedding with NaN values', () => {
|
||||
const embedding = [0.1, NaN, 0.3, 0.4, 0.5]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding contains NaN values')
|
||||
})
|
||||
|
||||
test('should reject embedding with Infinity values', () => {
|
||||
const embedding = [0.1, 0.2, Infinity, 0.4, 0.5]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding contains Infinity values')
|
||||
})
|
||||
|
||||
test('should reject zero vector', () => {
|
||||
const embedding = [0, 0, 0, 0, 0]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues).toContain('Embedding is a zero vector (all values are 0)')
|
||||
})
|
||||
|
||||
test('should warn about L2 norm outside normal range', () => {
|
||||
// Very small norm
|
||||
const smallEmbedding = [0.01, 0.01, 0.01]
|
||||
const result1 = validateEmbedding(smallEmbedding)
|
||||
|
||||
expect(result1.valid).toBe(false)
|
||||
expect(result1.issues.some(issue => issue.includes('L2 norm'))).toBe(true)
|
||||
|
||||
// Very large norm
|
||||
const largeEmbedding = [2, 2, 2]
|
||||
const result2 = validateEmbedding(largeEmbedding)
|
||||
|
||||
expect(result2.valid).toBe(false)
|
||||
expect(result2.issues.some(issue => issue.includes('L2 norm'))).toBe(true)
|
||||
})
|
||||
|
||||
test('should detect multiple issues', () => {
|
||||
const embedding = [NaN, Infinity, 0]
|
||||
const result = validateEmbedding(embedding)
|
||||
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.issues.length).toBeGreaterThan(1)
|
||||
expect(result.issues).toContain('Embedding contains NaN values')
|
||||
expect(result.issues).toContain('Embedding contains Infinity values')
|
||||
// Note: NaN and Infinity are not zero, so it won't detect zero vector
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('calculateL2Norm()', () => {
|
||||
test('should calculate correct L2 norm', () => {
|
||||
const vector = [3, 4]
|
||||
const norm = calculateL2Norm(vector)
|
||||
|
||||
expect(norm).toBe(5) // sqrt(3^2 + 4^2) = 5
|
||||
})
|
||||
|
||||
test('should return 0 for zero vector', () => {
|
||||
const vector = [0, 0, 0]
|
||||
const norm = calculateL2Norm(vector)
|
||||
|
||||
expect(norm).toBe(0)
|
||||
})
|
||||
|
||||
test('should handle negative values', () => {
|
||||
const vector = [-3, -4]
|
||||
const norm = calculateL2Norm(vector)
|
||||
|
||||
expect(norm).toBe(5) // sqrt((-3)^2 + (-4)^2) = 5
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('normalizeEmbedding()', () => {
|
||||
test('should normalize a vector to unit L2 norm', () => {
|
||||
const embedding = [3, 4]
|
||||
const normalized = normalizeEmbedding(embedding)
|
||||
const norm = calculateL2Norm(normalized)
|
||||
|
||||
expect(norm).toBeCloseTo(1.0, 5)
|
||||
})
|
||||
|
||||
test('should preserve direction of vector', () => {
|
||||
const embedding = [1, 2, 3]
|
||||
const normalized = normalizeEmbedding(embedding)
|
||||
|
||||
// Check that ratios are preserved
|
||||
expect(normalized[1] / normalized[0]).toBeCloseTo(embedding[1] / embedding[0], 5)
|
||||
expect(normalized[2] / normalized[1]).toBeCloseTo(embedding[2] / embedding[1], 5)
|
||||
})
|
||||
|
||||
test('should return zero vector unchanged', () => {
|
||||
const embedding = [0, 0, 0]
|
||||
const normalized = normalizeEmbedding(embedding)
|
||||
|
||||
expect(normalized).toEqual(embedding)
|
||||
})
|
||||
|
||||
test('should handle already normalized vectors', () => {
|
||||
const embedding = [0.707, 0.707] // Already approximately unit norm
|
||||
const normalized = normalizeEmbedding(embedding)
|
||||
const norm = calculateL2Norm(normalized)
|
||||
|
||||
expect(norm).toBeCloseTo(1.0, 5)
|
||||
})
|
||||
})
|
||||
})
|
||||
152
keep-notes/tests/unit/rrf.test.ts
Normal file
152
keep-notes/tests/unit/rrf.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { calculateRRFK } from '../../lib/utils';
|
||||
|
||||
test.describe('RRF K Calculation Tests', () => {
|
||||
test('should return minimum k=20 for small datasets', () => {
|
||||
// For small datasets (< 200 notes), k should be 20
|
||||
expect(calculateRRFK(0)).toBe(20);
|
||||
expect(calculateRRFK(10)).toBe(20);
|
||||
expect(calculateRRFK(50)).toBe(20);
|
||||
expect(calculateRRFK(100)).toBe(20);
|
||||
expect(calculateRRFK(199)).toBe(20);
|
||||
});
|
||||
|
||||
test('should return k=20 for exactly 200 notes', () => {
|
||||
expect(calculateRRFK(200)).toBe(20);
|
||||
});
|
||||
|
||||
test('should scale k for larger datasets', () => {
|
||||
// k = max(20, totalNotes / 10)
|
||||
expect(calculateRRFK(500)).toBe(50); // 500/10 = 50
|
||||
expect(calculateRRFK(1000)).toBe(100); // 1000/10 = 100
|
||||
expect(calculateRRFK(250)).toBe(25); // 250/10 = 25
|
||||
});
|
||||
|
||||
test('should handle edge cases', () => {
|
||||
expect(calculateRRFK(201)).toBe(20); // 201/10 = 20.1 → floor → 20, max(20,20) = 20
|
||||
expect(calculateRRFK(210)).toBe(21); // 210/10 = 21
|
||||
expect(calculateRRFK(10000)).toBe(1000); // 10000/10 = 1000
|
||||
});
|
||||
|
||||
test('k should always be at least 20', () => {
|
||||
// Even for very small datasets, k should not be below 20
|
||||
for (let i = 0; i <= 200; i++) {
|
||||
expect(calculateRRFK(i)).toBeGreaterThanOrEqual(20);
|
||||
}
|
||||
});
|
||||
|
||||
test('should be lower than old value for typical datasets', () => {
|
||||
// For typical user datasets (< 500 notes), new k should be lower than old k=60
|
||||
expect(calculateRRFK(100)).toBeLessThan(60);
|
||||
expect(calculateRRFK(200)).toBeLessThan(60);
|
||||
expect(calculateRRFK(300)).toBe(30); // 300/10 = 30
|
||||
expect(calculateRRFK(500)).toBe(50); // 500/10 = 50
|
||||
});
|
||||
|
||||
test('should surpass old value for very large datasets', () => {
|
||||
// For very large datasets (> 600 notes), k should be higher than 60
|
||||
expect(calculateRRFK(700)).toBe(70); // 700/10 = 70
|
||||
expect(calculateRRFK(1000)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('RRF Ranking Behavior Tests', () => {
|
||||
test('RRF with lower k penalizes low ranks more', () => {
|
||||
// Simulate RRF scores for a note at rank 5
|
||||
// Formula: score = 1 / (k + rank)
|
||||
|
||||
const rank = 5;
|
||||
const scoreWithK20 = 1 / (20 + rank); // 1/25 = 0.04
|
||||
const scoreWithK60 = 1 / (60 + rank); // 1/65 = 0.015
|
||||
|
||||
// Lower k gives higher score to low ranks
|
||||
expect(scoreWithK20).toBeGreaterThan(scoreWithK60);
|
||||
});
|
||||
|
||||
test('RRF favors items ranked high in both lists', () => {
|
||||
// Note A: rank 1 in both lists
|
||||
const scoreA_K20 = 1 / (20 + 1) + 1 / (20 + 1); // 2/21 ≈ 0.095
|
||||
|
||||
// Note B: rank 1 in one list, rank 10 in other
|
||||
const scoreB_K20 = 1 / (20 + 1) + 1 / (20 + 10); // 1/21 + 1/30 ≈ 0.081
|
||||
|
||||
// Note C: rank 5 in both lists
|
||||
const scoreC_K20 = 1 / (20 + 5) + 1 / (20 + 5); // 2/25 = 0.08
|
||||
|
||||
// Note A should have highest score (consistently high)
|
||||
expect(scoreA_K20).toBeGreaterThan(scoreB_K20);
|
||||
expect(scoreA_K20).toBeGreaterThan(scoreC_K20);
|
||||
});
|
||||
|
||||
test('k=20 vs k=60 ranking difference', () => {
|
||||
// Note at rank 20
|
||||
const rank = 20;
|
||||
|
||||
const scoreWithK20 = 1 / (20 + rank); // 1/40 = 0.025
|
||||
const scoreWithK60 = 1 / (60 + rank); // 1/80 = 0.0125
|
||||
|
||||
// With k=20, rank 20 is scored 2x higher than with k=60
|
||||
expect(scoreWithK20 / scoreWithK60).toBeCloseTo(2.0, 1);
|
||||
});
|
||||
|
||||
test('RRF score should decrease as rank increases', () => {
|
||||
const k = 20;
|
||||
|
||||
const scoreRank1 = 1 / (k + 1);
|
||||
const scoreRank5 = 1 / (k + 5);
|
||||
const scoreRank10 = 1 / (k + 10);
|
||||
const scoreRank50 = 1 / (k + 50);
|
||||
|
||||
expect(scoreRank1).toBeGreaterThan(scoreRank5);
|
||||
expect(scoreRank5).toBeGreaterThan(scoreRank10);
|
||||
expect(scoreRank10).toBeGreaterThan(scoreRank50);
|
||||
});
|
||||
|
||||
test('RRF handles missing ranks gracefully', () => {
|
||||
// If a note is not in a list, it gets the max rank
|
||||
const k = 20;
|
||||
const totalNotes = 100;
|
||||
const missingRank = totalNotes; // Treated as worst rank
|
||||
|
||||
const scoreWithMissing = 1 / (k + missingRank);
|
||||
const scoreWithRank50 = 1 / (k + 50);
|
||||
|
||||
// Missing rank should give much lower score
|
||||
expect(scoreWithMissing).toBeLessThan(scoreWithRank50);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('RRF Adaptive Behavior Tests', () => {
|
||||
test('adaptive k provides better rankings for small datasets', () => {
|
||||
// For 50 notes: k=20 (adaptive) vs k=60 (old)
|
||||
const totalNotes = 50;
|
||||
const kAdaptive = calculateRRFK(totalNotes); // 20
|
||||
const kOld = 60;
|
||||
|
||||
// Compare scores for rank 10 (20% of dataset)
|
||||
const rank = 10;
|
||||
const scoreAdaptive = 1 / (kAdaptive + rank);
|
||||
const scoreOld = 1 / (kOld + rank);
|
||||
|
||||
// Adaptive k gives higher score to mid-rank items in small datasets
|
||||
expect(scoreAdaptive).toBeGreaterThan(scoreOld);
|
||||
});
|
||||
|
||||
test('adaptive k scales appropriately', () => {
|
||||
const datasets = [10, 50, 100, 200, 500, 1000];
|
||||
|
||||
datasets.forEach(notes => {
|
||||
const k = calculateRRFK(notes);
|
||||
|
||||
// k should always be at least 20
|
||||
expect(k).toBeGreaterThanOrEqual(20);
|
||||
|
||||
// k should scale with dataset size
|
||||
if (notes < 200) {
|
||||
expect(k).toBe(20);
|
||||
} else {
|
||||
expect(k).toBe(Math.floor(notes / 10));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user