@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
text/typescript
/**
* 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)
})
);
});
});
});