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.
436 lines
13 KiB
436 lines
13 KiB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
|
import { writable, get } from 'svelte/store'; |
|
import { tick } from 'svelte'; |
|
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
|
|
|
// Mock stores and components |
|
vi.mock('$lib/stores/visualizationConfig', () => { |
|
const mockStore = writable({ |
|
maxPublicationIndices: -1, |
|
maxEventsPerIndex: -1, |
|
searchThroughFetched: false |
|
}); |
|
|
|
return { |
|
visualizationConfig: { |
|
subscribe: mockStore.subscribe, |
|
setMaxPublicationIndices: vi.fn((value: number) => { |
|
mockStore.update(s => ({ ...s, maxPublicationIndices: value })); |
|
}), |
|
setMaxEventsPerIndex: vi.fn((value: number) => { |
|
mockStore.update(s => ({ ...s, maxEventsPerIndex: value })); |
|
}), |
|
toggleSearchThroughFetched: vi.fn(() => { |
|
mockStore.update(s => ({ ...s, searchThroughFetched: !s.searchThroughFetched })); |
|
}) |
|
} |
|
}; |
|
}); |
|
|
|
vi.mock('$lib/stores/displayLimits', () => { |
|
const mockStore = writable({ |
|
max30040: -1, |
|
max30041: -1, |
|
fetchIfNotFound: false |
|
}); |
|
|
|
return { |
|
displayLimits: mockStore |
|
}; |
|
}); |
|
|
|
describe('Extended Visualization Reactivity Tests', () => { |
|
let updateCount = 0; |
|
let lastUpdateType: string | null = null; |
|
let simulationRestarts = 0; |
|
|
|
// Mock updateGraph function |
|
const mockUpdateGraph = vi.fn((type: string) => { |
|
updateCount++; |
|
lastUpdateType = type; |
|
}); |
|
|
|
// Mock simulation restart |
|
const mockRestartSimulation = vi.fn(() => { |
|
simulationRestarts++; |
|
}); |
|
|
|
beforeEach(() => { |
|
updateCount = 0; |
|
lastUpdateType = null; |
|
simulationRestarts = 0; |
|
vi.clearAllMocks(); |
|
}); |
|
|
|
describe('Parameter Update Paths', () => { |
|
it('should trigger data fetch for networkFetchLimit changes', async () => { |
|
const params = { |
|
networkFetchLimit: 50, |
|
levelsToRender: 2, |
|
showTagAnchors: false, |
|
starVisualization: false, |
|
tagExpansionDepth: 0 |
|
}; |
|
|
|
// Change networkFetchLimit |
|
const oldParams = { ...params }; |
|
params.networkFetchLimit = 100; |
|
|
|
const needsFetch = params.networkFetchLimit !== oldParams.networkFetchLimit; |
|
expect(needsFetch).toBe(true); |
|
|
|
if (needsFetch) { |
|
mockUpdateGraph('fetch-required'); |
|
} |
|
|
|
expect(mockUpdateGraph).toHaveBeenCalledWith('fetch-required'); |
|
expect(lastUpdateType).toBe('fetch-required'); |
|
}); |
|
|
|
it('should trigger data fetch for levelsToRender changes', async () => { |
|
const params = { |
|
networkFetchLimit: 50, |
|
levelsToRender: 2, |
|
showTagAnchors: false, |
|
starVisualization: false, |
|
tagExpansionDepth: 0 |
|
}; |
|
|
|
// Change levelsToRender |
|
const oldParams = { ...params }; |
|
params.levelsToRender = 3; |
|
|
|
const needsFetch = params.levelsToRender !== oldParams.levelsToRender; |
|
expect(needsFetch).toBe(true); |
|
|
|
if (needsFetch) { |
|
mockUpdateGraph('fetch-required'); |
|
} |
|
|
|
expect(mockUpdateGraph).toHaveBeenCalledWith('fetch-required'); |
|
}); |
|
|
|
it('should trigger fetch for tagExpansionDepth when > 0', async () => { |
|
const params = { |
|
tagExpansionDepth: 0, |
|
showTagAnchors: true |
|
}; |
|
|
|
// Change to depth > 0 |
|
const oldParams = { ...params }; |
|
params.tagExpansionDepth = 1; |
|
|
|
const needsFetch = params.tagExpansionDepth > 0 && |
|
params.tagExpansionDepth !== oldParams.tagExpansionDepth; |
|
expect(needsFetch).toBe(true); |
|
|
|
if (needsFetch) { |
|
mockUpdateGraph('tag-expansion-fetch'); |
|
} |
|
|
|
expect(mockUpdateGraph).toHaveBeenCalledWith('tag-expansion-fetch'); |
|
}); |
|
|
|
it('should not trigger fetch for tagExpansionDepth = 0', async () => { |
|
const params = { |
|
tagExpansionDepth: 2, |
|
showTagAnchors: true |
|
}; |
|
|
|
// Change to depth = 0 |
|
const oldParams = { ...params }; |
|
params.tagExpansionDepth = 0; |
|
|
|
const needsFetch = params.tagExpansionDepth > 0; |
|
expect(needsFetch).toBe(false); |
|
|
|
if (!needsFetch) { |
|
mockUpdateGraph('visual-only'); |
|
} |
|
|
|
expect(mockUpdateGraph).toHaveBeenCalledWith('visual-only'); |
|
}); |
|
|
|
it('should handle visual-only parameter changes', async () => { |
|
const visualParams = [ |
|
{ param: 'showTagAnchors', oldValue: false, newValue: true }, |
|
{ param: 'starVisualization', oldValue: false, newValue: true }, |
|
{ param: 'selectedTagType', oldValue: 't', newValue: 'p' } |
|
]; |
|
|
|
visualParams.forEach(({ param, oldValue, newValue }) => { |
|
vi.clearAllMocks(); |
|
|
|
const needsFetch = false; // Visual parameters never need fetch |
|
if (!needsFetch) { |
|
mockUpdateGraph('visual-only'); |
|
} |
|
|
|
expect(mockUpdateGraph).toHaveBeenCalledWith('visual-only'); |
|
expect(mockUpdateGraph).toHaveBeenCalledTimes(1); |
|
}); |
|
}); |
|
}); |
|
|
|
describe('Display Limits Integration', () => { |
|
it('should handle maxPublicationIndices changes', async () => { |
|
const { visualizationConfig } = await import('$lib/stores/visualizationConfig'); |
|
const { displayLimits } = await import('$lib/stores/displayLimits'); |
|
|
|
let configValue: any; |
|
const unsubscribe = visualizationConfig.subscribe(v => configValue = v); |
|
|
|
// Set new limit |
|
visualizationConfig.setMaxPublicationIndices(10); |
|
await tick(); |
|
|
|
expect(configValue.maxPublicationIndices).toBe(10); |
|
|
|
// This should trigger a visual update (filtering existing data) |
|
mockUpdateGraph('filter-existing'); |
|
expect(mockUpdateGraph).toHaveBeenCalledWith('filter-existing'); |
|
|
|
unsubscribe(); |
|
}); |
|
|
|
it('should handle unlimited (-1) values correctly', async () => { |
|
const { displayLimits } = await import('$lib/stores/displayLimits'); |
|
|
|
let limitsValue: any; |
|
const unsubscribe = displayLimits.subscribe(v => limitsValue = v); |
|
|
|
// Set to unlimited |
|
displayLimits.update(limits => ({ |
|
...limits, |
|
max30040: -1, |
|
max30041: -1 |
|
})); |
|
await tick(); |
|
|
|
expect(limitsValue.max30040).toBe(-1); |
|
expect(limitsValue.max30041).toBe(-1); |
|
|
|
// Unlimited should show all events |
|
const shouldFilter = limitsValue.max30040 !== -1 || limitsValue.max30041 !== -1; |
|
expect(shouldFilter).toBe(false); |
|
|
|
unsubscribe(); |
|
}); |
|
|
|
it('should handle fetchIfNotFound toggle', async () => { |
|
const { displayLimits } = await import('$lib/stores/displayLimits'); |
|
|
|
let limitsValue: any; |
|
const unsubscribe = displayLimits.subscribe(v => limitsValue = v); |
|
|
|
// Toggle fetchIfNotFound |
|
displayLimits.update(limits => ({ |
|
...limits, |
|
fetchIfNotFound: true |
|
})); |
|
await tick(); |
|
|
|
expect(limitsValue.fetchIfNotFound).toBe(true); |
|
|
|
// This should potentially trigger fetches for missing events |
|
if (limitsValue.fetchIfNotFound) { |
|
mockUpdateGraph('fetch-missing'); |
|
} |
|
|
|
expect(mockUpdateGraph).toHaveBeenCalledWith('fetch-missing'); |
|
|
|
unsubscribe(); |
|
}); |
|
}); |
|
|
|
describe('State Synchronization', () => { |
|
it('should maintain consistency between related parameters', async () => { |
|
let showTagAnchors = false; |
|
let tagExpansionDepth = 2; |
|
let selectedTagType = 't'; |
|
|
|
// When disabling tag anchors, depth should reset |
|
showTagAnchors = false; |
|
if (!showTagAnchors && tagExpansionDepth > 0) { |
|
tagExpansionDepth = 0; |
|
} |
|
|
|
expect(tagExpansionDepth).toBe(0); |
|
|
|
// When enabling tag anchors, previous values can be restored |
|
showTagAnchors = true; |
|
// selectedTagType should remain unchanged |
|
expect(selectedTagType).toBe('t'); |
|
}); |
|
|
|
it('should handle disabled tags state updates', async () => { |
|
const disabledTags = new Set<string>(); |
|
const tagAnchors = [ |
|
{ id: 't-bitcoin', type: 't', label: 'bitcoin' }, |
|
{ id: 't-nostr', type: 't', label: 'nostr' } |
|
]; |
|
|
|
// Toggle tag state |
|
const tagId = 't-bitcoin'; |
|
if (disabledTags.has(tagId)) { |
|
disabledTags.delete(tagId); |
|
} else { |
|
disabledTags.add(tagId); |
|
} |
|
|
|
expect(disabledTags.has('t-bitcoin')).toBe(true); |
|
expect(disabledTags.has('t-nostr')).toBe(false); |
|
|
|
// Visual update only |
|
mockUpdateGraph('tag-filter'); |
|
expect(mockUpdateGraph).toHaveBeenCalledWith('tag-filter'); |
|
}); |
|
}); |
|
|
|
describe('Performance and Memory Management', () => { |
|
it('should debounce rapid parameter changes', async () => { |
|
const debounceDelay = 100; |
|
let pendingUpdate: any = null; |
|
let updateTimer: any = null; |
|
|
|
const debouncedUpdate = (type: string) => { |
|
if (updateTimer) clearTimeout(updateTimer); |
|
|
|
pendingUpdate = type; |
|
updateTimer = setTimeout(() => { |
|
mockUpdateGraph(pendingUpdate); |
|
pendingUpdate = null; |
|
}, debounceDelay); |
|
}; |
|
|
|
// Rapid changes |
|
debouncedUpdate('change1'); |
|
debouncedUpdate('change2'); |
|
debouncedUpdate('change3'); |
|
|
|
// Should not have called update yet |
|
expect(mockUpdateGraph).not.toHaveBeenCalled(); |
|
|
|
// Wait for debounce |
|
await new Promise(resolve => setTimeout(resolve, debounceDelay + 10)); |
|
|
|
// Should only call once with last change |
|
expect(mockUpdateGraph).toHaveBeenCalledTimes(1); |
|
expect(mockUpdateGraph).toHaveBeenCalledWith('change3'); |
|
}); |
|
|
|
it('should clean up position cache for removed nodes', () => { |
|
const positionCache = new Map<string, { x: number; y: number }>(); |
|
const maxCacheSize = 1000; |
|
|
|
// Add positions |
|
for (let i = 0; i < 1500; i++) { |
|
positionCache.set(`node${i}`, { x: i * 10, y: i * 10 }); |
|
} |
|
|
|
// Clean up old entries if cache too large |
|
if (positionCache.size > maxCacheSize) { |
|
const entriesToKeep = Array.from(positionCache.entries()) |
|
.slice(-maxCacheSize); |
|
positionCache.clear(); |
|
entriesToKeep.forEach(([k, v]) => positionCache.set(k, v)); |
|
} |
|
|
|
expect(positionCache.size).toBe(maxCacheSize); |
|
}); |
|
|
|
it('should restart simulation efficiently', () => { |
|
const needsSimulationRestart = (paramChanged: string) => { |
|
const restartParams = ['starVisualization', 'showTagAnchors']; |
|
return restartParams.includes(paramChanged); |
|
}; |
|
|
|
// Test various parameter changes |
|
expect(needsSimulationRestart('starVisualization')).toBe(true); |
|
expect(needsSimulationRestart('showTagAnchors')).toBe(true); |
|
expect(needsSimulationRestart('selectedTagType')).toBe(false); |
|
|
|
// Only restart when necessary |
|
if (needsSimulationRestart('starVisualization')) { |
|
mockRestartSimulation(); |
|
} |
|
|
|
expect(mockRestartSimulation).toHaveBeenCalledTimes(1); |
|
}); |
|
}); |
|
|
|
describe('Edge Cases and Error Handling', () => { |
|
it('should handle empty event arrays gracefully', () => { |
|
const events: NDKEvent[] = []; |
|
const graph = { nodes: [], links: [] }; |
|
|
|
// Should not crash with empty data |
|
expect(() => { |
|
if (events.length === 0) { |
|
mockUpdateGraph('empty-data'); |
|
} |
|
}).not.toThrow(); |
|
|
|
expect(mockUpdateGraph).toHaveBeenCalledWith('empty-data'); |
|
}); |
|
|
|
it('should handle parameter validation', () => { |
|
const validateParams = (params: any) => { |
|
const errors: string[] = []; |
|
|
|
if (params.networkFetchLimit < 1) { |
|
errors.push('networkFetchLimit must be >= 1'); |
|
} |
|
if (params.levelsToRender < 0) { |
|
errors.push('levelsToRender must be >= 0'); |
|
} |
|
if (params.tagExpansionDepth < 0 || params.tagExpansionDepth > 10) { |
|
errors.push('tagExpansionDepth must be between 0 and 10'); |
|
} |
|
|
|
return errors; |
|
}; |
|
|
|
const invalidParams = { |
|
networkFetchLimit: 0, |
|
levelsToRender: -1, |
|
tagExpansionDepth: 15 |
|
}; |
|
|
|
const errors = validateParams(invalidParams); |
|
expect(errors).toHaveLength(3); |
|
expect(errors).toContain('networkFetchLimit must be >= 1'); |
|
}); |
|
|
|
it('should handle concurrent updates safely', async () => { |
|
let isUpdating = false; |
|
const updates: string[] = []; |
|
|
|
const safeUpdate = async (type: string) => { |
|
if (isUpdating) { |
|
// Queue update |
|
return new Promise(resolve => { |
|
setTimeout(() => safeUpdate(type).then(resolve), 10); |
|
}); |
|
} |
|
|
|
isUpdating = true; |
|
updates.push(type); |
|
await new Promise(resolve => setTimeout(resolve, 50)); |
|
isUpdating = false; |
|
}; |
|
|
|
// Trigger concurrent updates |
|
const promises = [ |
|
safeUpdate('update1'), |
|
safeUpdate('update2'), |
|
safeUpdate('update3') |
|
]; |
|
|
|
await Promise.all(promises); |
|
|
|
// All updates should complete |
|
expect(updates).toHaveLength(3); |
|
}); |
|
}); |
|
}); |