clone of repo on github
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

859 lines
24 KiB

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { pubkeyToHue } from '../../src/lib/utils/nostrUtils';
import { nip19 } from 'nostr-tools';
describe('pubkeyToHue', () => {
describe('Consistency', () => {
it('returns consistent hue for same pubkey', () => {
const pubkey = 'a'.repeat(64);
const hue1 = pubkeyToHue(pubkey);
const hue2 = pubkeyToHue(pubkey);
expect(hue1).toBe(hue2);
});
it('returns same hue for same pubkey called multiple times', () => {
const pubkey = 'abc123def456'.repeat(5) + 'abcd';
const hues = Array.from({ length: 10 }, () => pubkeyToHue(pubkey));
expect(new Set(hues).size).toBe(1); // All hues should be the same
});
});
describe('Range Validation', () => {
it('returns hue in valid range (0-360)', () => {
const pubkeys = [
'a'.repeat(64),
'f'.repeat(64),
'0'.repeat(64),
'9'.repeat(64),
'abc123def456'.repeat(5) + 'abcd',
'123456789abc'.repeat(5) + 'def0',
];
pubkeys.forEach(pubkey => {
const hue = pubkeyToHue(pubkey);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
});
it('returns integer hue value', () => {
const pubkey = 'a'.repeat(64);
const hue = pubkeyToHue(pubkey);
expect(Number.isInteger(hue)).toBe(true);
});
});
describe('Format Handling', () => {
it('handles hex format pubkeys', () => {
const hexPubkey = 'abcdef123456789'.repeat(4) + '0123';
const hue = pubkeyToHue(hexPubkey);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('handles npub format pubkeys', () => {
const hexPubkey = 'a'.repeat(64);
const npub = nip19.npubEncode(hexPubkey);
const hue = pubkeyToHue(npub);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('returns same hue for hex and npub format of same pubkey', () => {
const hexPubkey = 'abc123def456'.repeat(5) + 'abcd';
const npub = nip19.npubEncode(hexPubkey);
const hueFromHex = pubkeyToHue(hexPubkey);
const hueFromNpub = pubkeyToHue(npub);
expect(hueFromHex).toBe(hueFromNpub);
});
});
describe('Uniqueness', () => {
it('different pubkeys generate different hues', () => {
const pubkey1 = 'a'.repeat(64);
const pubkey2 = 'b'.repeat(64);
const pubkey3 = 'c'.repeat(64);
const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2);
const hue3 = pubkeyToHue(pubkey3);
expect(hue1).not.toBe(hue2);
expect(hue2).not.toBe(hue3);
expect(hue1).not.toBe(hue3);
});
it('generates diverse hues for multiple pubkeys', () => {
const pubkeys = Array.from({ length: 10 }, (_, i) =>
String.fromCharCode(97 + i).repeat(64)
);
const hues = pubkeys.map(pk => pubkeyToHue(pk));
const uniqueHues = new Set(hues);
// Most pubkeys should generate unique hues (allowing for some collisions)
expect(uniqueHues.size).toBeGreaterThan(7);
});
});
describe('Edge Cases', () => {
it('handles empty string input', () => {
const hue = pubkeyToHue('');
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('handles invalid npub format gracefully', () => {
const invalidNpub = 'npub1invalid';
const hue = pubkeyToHue(invalidNpub);
// Should still return a valid hue even if decode fails
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('handles short input strings', () => {
const shortInput = 'abc';
const hue = pubkeyToHue(shortInput);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
it('handles special characters', () => {
const specialInput = '!@#$%^&*()';
const hue = pubkeyToHue(specialInput);
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
});
});
describe('Color Distribution', () => {
it('distributes colors across the spectrum', () => {
// Generate hues for many different pubkeys
const pubkeys = Array.from({ length: 50 }, (_, i) =>
i.toString().repeat(16)
);
const hues = pubkeys.map(pk => pubkeyToHue(pk));
// Check that we have hues in different ranges of the spectrum
const hasLowHues = hues.some(h => h < 120);
const hasMidHues = hues.some(h => h >= 120 && h < 240);
const hasHighHues = hues.some(h => h >= 240);
expect(hasLowHues).toBe(true);
expect(hasMidHues).toBe(true);
expect(hasHighHues).toBe(true);
});
});
});
describe('HighlightLayer Component', () => {
let mockNdk: any;
let mockSubscription: any;
let eventHandlers: Map<string, Function>;
beforeEach(() => {
eventHandlers = new Map();
// Mock NDK subscription
mockSubscription = {
on: vi.fn((event: string, handler: Function) => {
eventHandlers.set(event, handler);
}),
stop: vi.fn(),
};
mockNdk = {
subscribe: vi.fn(() => mockSubscription),
};
// Mock DOM APIs
global.document = {
createTreeWalker: vi.fn(() => ({
nextNode: vi.fn(() => null),
})),
createDocumentFragment: vi.fn(() => ({
appendChild: vi.fn(),
})),
createTextNode: vi.fn((text: string) => ({
textContent: text,
})),
createElement: vi.fn((tag: string) => ({
className: '',
style: {},
textContent: '',
})),
} as any;
});
afterEach(() => {
vi.clearAllMocks();
});
describe('NDK Subscription', () => {
it('fetches kind 9802 events with correct filter when eventId provided', () => {
const eventId = 'a'.repeat(64);
// Simulate calling fetchHighlights
mockNdk.subscribe({ kinds: [9802], '#e': [eventId], limit: 100 });
expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
'#e': [eventId],
limit: 100,
})
);
});
it('fetches kind 9802 events with correct filter when eventAddress provided', () => {
const eventAddress = '30040:' + 'a'.repeat(64) + ':chapter-1';
// Simulate calling fetchHighlights
mockNdk.subscribe({ kinds: [9802], '#a': [eventAddress], limit: 100 });
expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
'#a': [eventAddress],
limit: 100,
})
);
});
it('fetches with both eventId and eventAddress filters when both provided', () => {
const eventId = 'a'.repeat(64);
const eventAddress = '30040:' + 'b'.repeat(64) + ':chapter-1';
// Simulate calling fetchHighlights
mockNdk.subscribe({
kinds: [9802],
'#e': [eventId],
'#a': [eventAddress],
limit: 100,
});
expect(mockNdk.subscribe).toHaveBeenCalledWith(
expect.objectContaining({
kinds: [9802],
'#e': [eventId],
'#a': [eventAddress],
limit: 100,
})
);
});
it('cleans up subscription on unmount', () => {
mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Simulate unmount by calling stop
mockSubscription.stop();
expect(mockSubscription.stop).toHaveBeenCalled();
});
});
describe('Color Mapping', () => {
it('maps highlights to colors correctly', () => {
const pubkey1 = 'a'.repeat(64);
const pubkey2 = 'b'.repeat(64);
const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2);
const expectedColor1 = `hsla(${hue1}, 70%, 60%, 0.3)`;
const expectedColor2 = `hsla(${hue2}, 70%, 60%, 0.3)`;
expect(expectedColor1).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
expect(expectedColor2).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
expect(expectedColor1).not.toBe(expectedColor2);
});
it('uses consistent color for same pubkey', () => {
const pubkey = 'abc123def456'.repeat(5) + 'abcd';
const hue = pubkeyToHue(pubkey);
const color1 = `hsla(${hue}, 70%, 60%, 0.3)`;
const color2 = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color1).toBe(color2);
});
it('generates semi-transparent colors with 0.3 opacity', () => {
const pubkey = 'a'.repeat(64);
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain('0.3');
});
it('uses HSL color format with correct values', () => {
const pubkey = 'a'.repeat(64);
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
// Verify format: hsla(hue, 70%, 60%, 0.3)
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
});
});
describe('Highlight Events', () => {
it('handles no highlights gracefully', () => {
const highlights: any[] = [];
expect(highlights.length).toBe(0);
// Component should render without errors
});
it('handles single highlight from one user', () => {
const mockHighlight = {
id: 'highlight1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: 'highlighted text',
created_at: Date.now(),
tags: [],
};
const highlights = [mockHighlight];
expect(highlights.length).toBe(1);
expect(highlights[0].pubkey).toBe('a'.repeat(64));
});
it('handles multiple highlights from same user', () => {
const pubkey = 'a'.repeat(64);
const mockHighlights = [
{
id: 'highlight1',
kind: 9802,
pubkey: pubkey,
content: 'first highlight',
created_at: Date.now(),
tags: [],
},
{
id: 'highlight2',
kind: 9802,
pubkey: pubkey,
content: 'second highlight',
created_at: Date.now(),
tags: [],
},
];
expect(mockHighlights.length).toBe(2);
expect(mockHighlights[0].pubkey).toBe(mockHighlights[1].pubkey);
// Should use same color for both
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
});
it('handles multiple highlights from different users', () => {
const pubkey1 = 'a'.repeat(64);
const pubkey2 = 'b'.repeat(64);
const pubkey3 = 'c'.repeat(64);
const mockHighlights = [
{
id: 'highlight1',
kind: 9802,
pubkey: pubkey1,
content: 'highlight from user 1',
created_at: Date.now(),
tags: [],
},
{
id: 'highlight2',
kind: 9802,
pubkey: pubkey2,
content: 'highlight from user 2',
created_at: Date.now(),
tags: [],
},
{
id: 'highlight3',
kind: 9802,
pubkey: pubkey3,
content: 'highlight from user 3',
created_at: Date.now(),
tags: [],
},
];
expect(mockHighlights.length).toBe(3);
// Each should have different color
const hue1 = pubkeyToHue(pubkey1);
const hue2 = pubkeyToHue(pubkey2);
const hue3 = pubkeyToHue(pubkey3);
expect(hue1).not.toBe(hue2);
expect(hue2).not.toBe(hue3);
expect(hue1).not.toBe(hue3);
});
it('prevents duplicate highlights', () => {
const mockHighlight = {
id: 'highlight1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: 'highlighted text',
created_at: Date.now(),
tags: [],
};
const highlights = [mockHighlight];
// Try to add duplicate
const isDuplicate = highlights.some(h => h.id === mockHighlight.id);
expect(isDuplicate).toBe(true);
// Should not add duplicate
});
it('handles empty content gracefully', () => {
const mockHighlight = {
id: 'highlight1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: '',
created_at: Date.now(),
tags: [],
};
// Should not crash
expect(mockHighlight.content).toBe('');
});
it('handles whitespace-only content', () => {
const mockHighlight = {
id: 'highlight1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: ' \n\t ',
created_at: Date.now(),
tags: [],
};
const trimmed = mockHighlight.content.trim();
expect(trimmed.length).toBe(0);
});
});
describe('Highlighter Legend', () => {
it('displays legend with correct color for single highlighter', () => {
const pubkey = 'abc123def456'.repeat(5) + 'abcd';
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
const legend = {
pubkey: pubkey,
color: color,
shortPubkey: `${pubkey.slice(0, 8)}...`,
};
expect(legend.color).toBe(color);
expect(legend.shortPubkey).toBe(`${pubkey.slice(0, 8)}...`);
});
it('displays legend with colors for multiple highlighters', () => {
const pubkeys = [
'a'.repeat(64),
'b'.repeat(64),
'c'.repeat(64),
];
const legendEntries = pubkeys.map(pubkey => ({
pubkey,
color: `hsla(${pubkeyToHue(pubkey)}, 70%, 60%, 0.3)`,
shortPubkey: `${pubkey.slice(0, 8)}...`,
}));
expect(legendEntries.length).toBe(3);
// Each should have unique color
const colors = legendEntries.map(e => e.color);
const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBe(3);
});
it('shows truncated pubkey in legend', () => {
const pubkey = 'abcdefghijklmnop'.repeat(4);
const shortPubkey = `${pubkey.slice(0, 8)}...`;
expect(shortPubkey).toBe('abcdefgh...');
expect(shortPubkey.length).toBeLessThan(pubkey.length);
});
it('displays highlight count', () => {
const highlights = [
{ id: '1', pubkey: 'a'.repeat(64), content: 'text1' },
{ id: '2', pubkey: 'b'.repeat(64), content: 'text2' },
{ id: '3', pubkey: 'a'.repeat(64), content: 'text3' },
];
expect(highlights.length).toBe(3);
// Count unique highlighters
const uniqueHighlighters = new Set(highlights.map(h => h.pubkey));
expect(uniqueHighlighters.size).toBe(2);
});
});
describe('Text Matching', () => {
it('matches text case-insensitively', () => {
const searchText = 'Hello World';
const contentText = 'hello world';
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0);
});
it('handles special characters in search text', () => {
const searchText = 'text with "quotes" and symbols!';
const contentText = 'This is text with "quotes" and symbols! in it.';
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0);
});
it('handles Unicode characters', () => {
const searchText = 'café résumé';
const contentText = 'The café résumé was excellent.';
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase());
expect(index).toBeGreaterThanOrEqual(0);
});
it('handles multi-line text', () => {
const searchText = 'line one\nline two';
const contentText = 'This is line one\nline two in the document.';
const index = contentText.indexOf(searchText);
expect(index).toBeGreaterThanOrEqual(0);
});
it('does not match partial words when searching for whole words', () => {
const searchText = 'cat';
const contentText = 'The category is important.';
// Simple word boundary check
const wordBoundaryMatch = new RegExp(`\\b${searchText}\\b`, 'i').test(contentText);
expect(wordBoundaryMatch).toBe(false);
});
});
describe('Subscription Lifecycle', () => {
it('registers EOSE event handler', () => {
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Verify that 'on' method is available for registering handlers
expect(subscription.on).toBeDefined();
// Register EOSE handler
subscription.on('eose', () => {
subscription.stop();
});
// Verify on was called
expect(subscription.on).toHaveBeenCalledWith('eose', expect.any(Function));
});
it('registers error event handler', () => {
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Verify that 'on' method is available for registering handlers
expect(subscription.on).toBeDefined();
// Register error handler
subscription.on('error', () => {
subscription.stop();
});
// Verify on was called
expect(subscription.on).toHaveBeenCalledWith('error', expect.any(Function));
});
it('stops subscription on timeout', async () => {
vi.useFakeTimers();
mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Fast-forward time by 10 seconds
vi.advanceTimersByTime(10000);
// Subscription should be stopped after timeout
// Note: This would be tested in the actual component
vi.useRealTimers();
});
it('handles multiple subscription cleanup calls safely', () => {
mockNdk.subscribe({ kinds: [9802], limit: 100 });
// Call stop multiple times
mockSubscription.stop();
mockSubscription.stop();
mockSubscription.stop();
expect(mockSubscription.stop).toHaveBeenCalledTimes(3);
// Should not throw errors
});
});
describe('Performance', () => {
it('handles large number of highlights efficiently', () => {
const startTime = Date.now();
const highlights = Array.from({ length: 1000 }, (_, i) => ({
id: `highlight${i}`,
kind: 9802,
pubkey: (i % 10).toString().repeat(64),
content: `highlighted text ${i}`,
created_at: Date.now(),
tags: [],
}));
// Generate colors for all highlights
const colorMap = new Map<string, string>();
highlights.forEach(h => {
if (!colorMap.has(h.pubkey)) {
const hue = pubkeyToHue(h.pubkey);
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
}
});
const endTime = Date.now();
const duration = endTime - startTime;
expect(highlights.length).toBe(1000);
expect(colorMap.size).toBe(10);
expect(duration).toBeLessThan(1000); // Should complete in less than 1 second
});
});
});
describe('Integration Tests', () => {
describe('Toggle Functionality', () => {
it('toggle button shows highlights when clicked', () => {
let highlightsVisible = false;
// Simulate toggle
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(true);
});
it('toggle button hides highlights when clicked again', () => {
let highlightsVisible = true;
// Simulate toggle
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(false);
});
it('toggle state persists between interactions', () => {
let highlightsVisible = false;
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(true);
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(false);
highlightsVisible = !highlightsVisible;
expect(highlightsVisible).toBe(true);
});
});
describe('Color Format Validation', () => {
it('generates semi-transparent colors with 0.3 opacity', () => {
const pubkeys = [
'a'.repeat(64),
'b'.repeat(64),
'c'.repeat(64),
];
pubkeys.forEach(pubkey => {
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain('0.3');
});
});
it('uses HSL color format with correct saturation and lightness', () => {
const pubkey = 'a'.repeat(64);
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
expect(color).toContain('70%');
expect(color).toContain('60%');
});
it('generates valid CSS color strings', () => {
const pubkeys = Array.from({ length: 20 }, (_, i) =>
String.fromCharCode(97 + i).repeat(64)
);
pubkeys.forEach(pubkey => {
const hue = pubkeyToHue(pubkey);
const color = `hsla(${hue}, 70%, 60%, 0.3)`;
// Validate CSS color format
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/);
});
});
});
describe('End-to-End Flow', () => {
it('complete highlight workflow', () => {
// 1. Start with no highlights visible
let highlightsVisible = false;
let highlights: any[] = [];
expect(highlightsVisible).toBe(false);
expect(highlights.length).toBe(0);
// 2. Fetch highlights
const mockHighlights = [
{
id: 'h1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: 'first highlight',
created_at: Date.now(),
tags: [],
},
{
id: 'h2',
kind: 9802,
pubkey: 'b'.repeat(64),
content: 'second highlight',
created_at: Date.now(),
tags: [],
},
];
highlights = mockHighlights;
expect(highlights.length).toBe(2);
// 3. Generate color map
const colorMap = new Map<string, string>();
highlights.forEach(h => {
if (!colorMap.has(h.pubkey)) {
const hue = pubkeyToHue(h.pubkey);
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`);
}
});
expect(colorMap.size).toBe(2);
// 4. Toggle visibility
highlightsVisible = true;
expect(highlightsVisible).toBe(true);
// 5. Verify colors are different
const colors = Array.from(colorMap.values());
expect(colors[0]).not.toBe(colors[1]);
// 6. Toggle off
highlightsVisible = false;
expect(highlightsVisible).toBe(false);
});
it('handles event updates correctly', () => {
let eventId = 'event1';
let highlights: any[] = [];
// Initial load
highlights = [
{
id: 'h1',
kind: 9802,
pubkey: 'a'.repeat(64),
content: 'highlight 1',
created_at: Date.now(),
tags: [],
},
];
expect(highlights.length).toBe(1);
// Event changes
eventId = 'event2';
highlights = [];
expect(highlights.length).toBe(0);
// New highlights loaded
highlights = [
{
id: 'h2',
kind: 9802,
pubkey: 'b'.repeat(64),
content: 'highlight 2',
created_at: Date.now(),
tags: [],
},
];
expect(highlights.length).toBe(1);
expect(highlights[0].id).toBe('h2');
});
});
describe('Error Handling', () => {
it('handles missing event ID and address gracefully', () => {
const eventId = undefined;
const eventAddress = undefined;
// Should not attempt to fetch
expect(eventId).toBeUndefined();
expect(eventAddress).toBeUndefined();
});
it('handles subscription errors gracefully', () => {
const error = new Error('Subscription failed');
// Should log error but not crash
expect(error.message).toBe('Subscription failed');
});
it('handles malformed highlight events', () => {
const malformedHighlight = {
id: 'h1',
kind: 9802,
pubkey: '', // Empty pubkey
content: undefined, // Missing content
created_at: Date.now(),
tags: [],
};
// Should handle gracefully
expect(malformedHighlight.pubkey).toBe('');
expect(malformedHighlight.content).toBeUndefined();
});
});
});