UNPKG

@tinytapanalytics/sdk

Version:

Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time

591 lines (477 loc) 15.8 kB
/** * Tests for Heatmap feature */ import { Heatmap } from '../Heatmap'; import type { TinyTapAnalyticsConfig } from '../../types/index'; describe('Heatmap', () => { let heatmap: Heatmap; let mockConfig: TinyTapAnalyticsConfig; let mockSdk: any; let originalRandom: () => number; beforeEach(() => { // Save original Math.random originalRandom = Math.random; // Create mock config mockConfig = { websiteId: 'test-website', apiUrl: 'https://api.test.com', debug: false }; // Create mock SDK mockSdk = { sessionId: 'test-session-123', userId: 'test-user-456', track: jest.fn() }; // Create heatmap instance heatmap = new Heatmap(mockConfig, mockSdk); // Mock console methods jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'warn').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { // Restore Math.random Math.random = originalRandom; jest.restoreAllMocks(); }); describe('Constructor', () => { it('should initialize with config and SDK', () => { expect(heatmap).toBeInstanceOf(Heatmap); const stats = heatmap.getStats(); expect(stats.isActive).toBe(false); expect(stats.totalPoints).toBe(0); }); }); describe('start()', () => { it('should start heatmap tracking when sampling allows', () => { // Mock Math.random to always return 0 (within sampling rate) Math.random = jest.fn(() => 0.05); heatmap.start(); const stats = heatmap.getStats(); expect(stats.isActive).toBe(true); }); it('should not start if already active', () => { Math.random = jest.fn(() => 0.05); heatmap.start(); heatmap.start(); // Try to start again const stats = heatmap.getStats(); expect(stats.isActive).toBe(true); }); it('should not start if sampling rate excludes user', () => { // Mock Math.random to return high value (outside sampling rate) Math.random = jest.fn(() => 0.95); heatmap.start(); const stats = heatmap.getStats(); expect(stats.isActive).toBe(false); }); it('should log when debug is enabled', () => { mockConfig.debug = true; heatmap = new Heatmap(mockConfig, mockSdk); Math.random = jest.fn(() => 0.05); heatmap.start(); expect(console.log).toHaveBeenCalledWith('TinyTapAnalytics: Heatmap tracking started'); }); }); describe('stop()', () => { beforeEach(() => { Math.random = jest.fn(() => 0.05); heatmap.start(); }); it('should stop heatmap tracking', () => { heatmap.stop(); const stats = heatmap.getStats(); expect(stats.isActive).toBe(false); }); it('should send data before stopping', () => { // Simulate a click to add data const clickEvent = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); document.dispatchEvent(clickEvent); heatmap.stop(); expect(mockSdk.track).toHaveBeenCalledWith('heatmap_data', expect.objectContaining({ url: expect.any(String), sessionId: 'test-session-123' })); }); it('should not do anything if not active', () => { heatmap.stop(); heatmap.stop(); // Try to stop again const stats = heatmap.getStats(); expect(stats.isActive).toBe(false); }); it('should log when debug is enabled', () => { mockConfig.debug = true; heatmap = new Heatmap(mockConfig, mockSdk); Math.random = jest.fn(() => 0.05); heatmap.start(); heatmap.stop(); expect(console.log).toHaveBeenCalledWith('TinyTapAnalytics: Heatmap tracking stopped'); }); }); describe('Click Tracking', () => { beforeEach(() => { Math.random = jest.fn(() => 0.05); heatmap.start(); }); it('should track click events', () => { const clickEvent = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); document.dispatchEvent(clickEvent); const stats = heatmap.getStats(); expect(stats.clickPoints).toBeGreaterThan(0); }); it('should cluster nearby clicks', () => { // Click at same location twice const click1 = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); const click2 = new MouseEvent('click', { clientX: 105, // Within clustering radius clientY: 205, bubbles: true }); document.dispatchEvent(click1); document.dispatchEvent(click2); const stats = heatmap.getStats(); // Should cluster into single point expect(stats.clickPoints).toBe(1); }); it('should create separate points for distant clicks', () => { const click1 = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); const click2 = new MouseEvent('click', { clientX: 500, // Far away clientY: 600, bubbles: true }); document.dispatchEvent(click1); document.dispatchEvent(click2); const stats = heatmap.getStats(); expect(stats.clickPoints).toBe(2); }); it('should not track clicks when inactive', () => { heatmap.stop(); const clickEvent = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); document.dispatchEvent(clickEvent); const stats = heatmap.getStats(); expect(stats.clickPoints).toBe(0); }); it('should limit maximum points', () => { // Generate more than maxPoints clicks for (let i = 0; i < 1200; i++) { const click = new MouseEvent('click', { clientX: i * 10, clientY: i * 10, bubbles: true }); document.dispatchEvent(click); } const stats = heatmap.getStats(); expect(stats.clickPoints).toBeLessThanOrEqual(1000); }); }); describe('Mouse Movement Tracking', () => { beforeEach(() => { Math.random = jest.fn(() => 0.05); heatmap.start(); }); it('should track mouse movements with sampling', () => { // Mock Math.random for move sampling (10% sample rate) Math.random = jest.fn(() => 0.05); // Within sample rate const moveEvent = new MouseEvent('mousemove', { clientX: 150, clientY: 250, bubbles: true }); document.dispatchEvent(moveEvent); const stats = heatmap.getStats(); expect(stats.movePoints).toBeGreaterThan(0); }); it('should not track all mouse movements (sampling)', () => { // Mock Math.random to return high value (outside sample rate) Math.random = jest.fn(() => 0.95); const moveEvent = new MouseEvent('mousemove', { clientX: 150, clientY: 250, bubbles: true }); document.dispatchEvent(moveEvent); const stats = heatmap.getStats(); expect(stats.movePoints).toBe(0); }); it('should throttle mouse movements', async () => { Math.random = jest.fn(() => 0.05); // Fire multiple events quickly for (let i = 0; i < 5; i++) { const moveEvent = new MouseEvent('mousemove', { clientX: 150 + i, clientY: 250 + i, bubbles: true }); document.dispatchEvent(moveEvent); } const stats = heatmap.getStats(); // Should be throttled, not all 5 tracked expect(stats.movePoints).toBeLessThan(5); }); }); describe('Scroll Tracking', () => { beforeEach(() => { Math.random = jest.fn(() => 0.05); heatmap.start(); }); it('should track scroll events', () => { const scrollEvent = new Event('scroll'); window.dispatchEvent(scrollEvent); const stats = heatmap.getStats(); expect(stats.scrollPoints).toBeGreaterThan(0); }); it('should throttle scroll events', () => { // Fire multiple scroll events quickly for (let i = 0; i < 10; i++) { const scrollEvent = new Event('scroll'); window.dispatchEvent(scrollEvent); } const stats = heatmap.getStats(); // Should be throttled expect(stats.scrollPoints).toBeLessThan(10); }); }); describe('Data Management', () => { beforeEach(() => { Math.random = jest.fn(() => 0.05); heatmap.start(); }); it('should clear all data', () => { // Add some data const clickEvent = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); document.dispatchEvent(clickEvent); heatmap.clearData(); const stats = heatmap.getStats(); expect(stats.totalPoints).toBe(0); }); it('should export heatmap data', () => { // Add some data const clickEvent = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); document.dispatchEvent(clickEvent); const exportedData = heatmap.exportData(); expect(exportedData).toEqual({ url: expect.any(String), viewport: { width: expect.any(Number), height: expect.any(Number) }, points: expect.arrayContaining([ expect.objectContaining({ x: expect.any(Number), y: expect.any(Number), intensity: expect.any(Number), timestamp: expect.any(Number), type: expect.any(String) }) ]), sessionId: 'test-session-123', userId: 'test-user-456' }); }); it('should get statistics', () => { const stats = heatmap.getStats(); expect(stats).toEqual({ isActive: true, clickPoints: expect.any(Number), movePoints: expect.any(Number), scrollPoints: expect.any(Number), totalPoints: expect.any(Number) }); }); }); describe('Sampling Rate', () => { it('should set sampling rate within bounds', () => { heatmap.setSamplingRate(0.5); // Sampling rate is private, but we can test behavior expect(true).toBe(true); // Rate was set successfully }); it('should clamp sampling rate to 0-1 range', () => { heatmap.setSamplingRate(1.5); heatmap.setSamplingRate(-0.5); // Should clamp to valid range expect(true).toBe(true); }); it('should log when debug is enabled', () => { mockConfig.debug = true; heatmap = new Heatmap(mockConfig, mockSdk); heatmap.setSamplingRate(0.5); expect(console.log).toHaveBeenCalledWith( 'TinyTapAnalytics: Heatmap sampling rate set to', expect.any(Number) ); }); }); describe('Visualization', () => { beforeEach(() => { Math.random = jest.fn(() => 0.05); heatmap.start(); }); it('should warn if tracking is not active', () => { heatmap.stop(); heatmap.generateHeatmapVisualization('test-container'); expect(console.warn).toHaveBeenCalledWith('TinyTapAnalytics: Heatmap tracking is not active'); }); it('should error if container not found', () => { heatmap.generateHeatmapVisualization('non-existent-container'); expect(console.error).toHaveBeenCalledWith('TinyTapAnalytics: Heatmap container not found'); }); it('should create canvas when container exists', () => { // Create container const container = document.createElement('div'); container.id = 'test-container'; document.body.appendChild(container); // Mock canvas context const mockContext = { beginPath: jest.fn(), arc: jest.fn(), fill: jest.fn(), fillStyle: '' }; HTMLCanvasElement.prototype.getContext = jest.fn(() => mockContext as any); heatmap.generateHeatmapVisualization('test-container'); const canvas = container.querySelector('canvas'); expect(canvas).toBeTruthy(); expect(canvas?.style.position).toBe('absolute'); expect(canvas?.style.zIndex).toBe('999999'); // Cleanup document.body.removeChild(container); }); }); describe('Page Unload Tracking', () => { beforeEach(() => { Math.random = jest.fn(() => 0.05); heatmap.start(); }); it('should send data on beforeunload', () => { // Add some data const clickEvent = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); document.dispatchEvent(clickEvent); const beforeUnloadEvent = new Event('beforeunload'); window.dispatchEvent(beforeUnloadEvent); expect(mockSdk.track).toHaveBeenCalledWith('heatmap_data', expect.any(Object)); }); it('should send data on visibility change', () => { // Add some data const clickEvent = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); document.dispatchEvent(clickEvent); // Mock document.hidden Object.defineProperty(document, 'hidden', { value: true, writable: true, configurable: true }); const visibilityEvent = new Event('visibilitychange'); document.dispatchEvent(visibilityEvent); expect(mockSdk.track).toHaveBeenCalled(); }); it('should not send data if no points collected', () => { const beforeUnloadEvent = new Event('beforeunload'); window.dispatchEvent(beforeUnloadEvent); expect(mockSdk.track).not.toHaveBeenCalled(); }); }); describe('Debounced Sending', () => { beforeEach(() => { Math.random = jest.fn(() => 0.05); heatmap.start(); jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); it('should debounce data sending', () => { // Generate multiple clicks quickly for (let i = 0; i < 5; i++) { const clickEvent = new MouseEvent('click', { clientX: 100 + i * 100, clientY: 200 + i * 100, bubbles: true }); document.dispatchEvent(clickEvent); } // Should not have sent yet expect(mockSdk.track).not.toHaveBeenCalledWith('heatmap_data', expect.any(Object)); // Fast-forward time jest.advanceTimersByTime(5000); // Should have sent after debounce expect(mockSdk.track).toHaveBeenCalledWith('heatmap_data', expect.any(Object)); }); it('should clear points after sending', () => { const clickEvent = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); document.dispatchEvent(clickEvent); jest.advanceTimersByTime(5000); const stats = heatmap.getStats(); expect(stats.clickPoints).toBe(0); }); }); describe('Debug Logging', () => { beforeEach(() => { mockConfig.debug = true; heatmap = new Heatmap(mockConfig, mockSdk); Math.random = jest.fn(() => 0.05); }); it('should log when clearing data', () => { heatmap.clearData(); expect(console.log).toHaveBeenCalledWith('TinyTapAnalytics: Heatmap data cleared'); }); it('should log when sending data', () => { heatmap.start(); const clickEvent = new MouseEvent('click', { clientX: 100, clientY: 200, bubbles: true }); document.dispatchEvent(clickEvent); heatmap.stop(); expect(console.log).toHaveBeenCalledWith( 'TinyTapAnalytics: Heatmap data sent', expect.objectContaining({ pointCount: expect.any(Number), url: expect.any(String) }) ); }); }); });