@tinytapanalytics/sdk
Version:
Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time
927 lines (731 loc) • 29.2 kB
text/typescript
/**
* Tests for AdvancedAnalytics feature
*/
import { AdvancedAnalytics } from '../AdvancedAnalytics';
import { TinyTapAnalyticsConfig } from '../../types/index';
describe('AdvancedAnalytics', () => {
let advancedAnalytics: AdvancedAnalytics;
let mockConfig: TinyTapAnalyticsConfig;
let mockSdk: any;
beforeEach(() => {
mockConfig = {
apiKey: 'test-key',
endpoint: 'https://api.test.com',
debug: false
};
mockSdk = {
track: jest.fn()
};
// Mock localStorage
Object.defineProperty(global, 'localStorage', {
value: {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn()
},
writable: true,
configurable: true
});
// Mock MutationObserver
global.MutationObserver = jest.fn().mockImplementation((callback) => ({
observe: jest.fn(),
disconnect: jest.fn(),
takeRecords: jest.fn()
}));
jest.spyOn(document, 'addEventListener').mockImplementation(() => {});
jest.spyOn(window, 'addEventListener').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
});
describe('initialization', () => {
it('should initialize session data', () => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
const sessionData = advancedAnalytics.getSessionData();
expect(sessionData.id).toContain('ciq_session_');
expect(sessionData.startTime).toBeGreaterThan(0);
expect(sessionData.lastActivity).toBeGreaterThan(0);
expect(sessionData.pageViews).toBe(0);
expect(sessionData.events).toBe(0);
expect(sessionData.duration).toBe(0);
});
it('should setup event listeners for session tracking', () => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
expect(document.addEventListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function));
expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function), expect.any(Object));
expect(document.addEventListener).toHaveBeenCalledWith('scroll', expect.any(Function), expect.any(Object));
expect(document.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function), expect.any(Object));
expect(document.addEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function), expect.any(Object));
expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
});
});
describe('trackPageView', () => {
beforeEach(() => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should track page view with default URL', () => {
advancedAnalytics.trackPageView();
expect(mockSdk.track).toHaveBeenCalledWith('enhanced_page_view', {
url: window.location.href,
title: document.title,
referrer: document.referrer,
timestamp: expect.any(Number),
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
screen: {
width: screen.width,
height: screen.height
},
sessionId: expect.stringContaining('ciq_session_'),
pageNumber: 1
});
});
it('should track page view with custom URL', () => {
advancedAnalytics.trackPageView('https://example.com/custom');
expect(mockSdk.track).toHaveBeenCalledWith('enhanced_page_view', expect.objectContaining({
url: 'https://example.com/custom',
pageNumber: 1
}));
});
it('should increment page view count', () => {
advancedAnalytics.trackPageView();
advancedAnalytics.trackPageView();
const sessionData = advancedAnalytics.getSessionData();
expect(sessionData.pageViews).toBe(2);
});
it('should add page view to user journey', () => {
advancedAnalytics.trackPageView();
const journey = advancedAnalytics.getUserJourney();
expect(journey.length).toBe(1);
expect(journey[0].event).toBe('page_view');
});
});
describe('trackEngagement', () => {
beforeEach(() => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
// Mock scroll properties
Object.defineProperty(document.documentElement, 'scrollHeight', {
configurable: true,
value: 2000
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 800
});
Object.defineProperty(window, 'pageYOffset', {
configurable: true,
value: 0
});
});
it('should track user engagement', () => {
advancedAnalytics.trackEngagement();
expect(mockSdk.track).toHaveBeenCalledWith('user_engagement', {
timeOnPage: expect.any(Number),
scrollDepth: expect.any(Number),
clickCount: 0,
sessionDuration: expect.any(Number),
pageViews: 0,
sessionId: expect.stringContaining('ciq_session_')
});
});
});
describe('setupFunnelAnalysis', () => {
beforeEach(() => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should setup funnel analysis with selectors', () => {
const steps = [
{ name: 'Step 1', selector: '.step-1' },
{ name: 'Step 2', selector: '.step-2' },
{ name: 'Step 3', selector: '.step-3' }
];
advancedAnalytics.setupFunnelAnalysis(steps);
expect(global.MutationObserver).toHaveBeenCalled();
});
it('should setup funnel analysis with URLs', () => {
const steps = [
{ name: 'Landing', url: '/landing' },
{ name: 'Signup', url: '/signup' },
{ name: 'Complete', url: '/complete' }
];
advancedAnalytics.setupFunnelAnalysis(steps);
// Should not crash
expect(true).toBe(true);
});
});
describe('trackCohort', () => {
beforeEach(() => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should track cohort with user attributes', () => {
const userAttributes = {
plan: 'premium',
signupDate: '2024-01-01'
};
advancedAnalytics.trackCohort('Premium Users', userAttributes);
expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', {
cohortName: 'Premium Users',
userAttributes,
sessionId: expect.stringContaining('ciq_session_'),
firstVisit: expect.any(Boolean),
timestamp: expect.any(Number)
});
});
it('should track cohort without user attributes', () => {
advancedAnalytics.trackCohort('All Users');
expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', {
cohortName: 'All Users',
userAttributes: {},
sessionId: expect.stringContaining('ciq_session_'),
firstVisit: expect.any(Boolean),
timestamp: expect.any(Number)
});
});
it('should detect first visit', () => {
(localStorage.getItem as jest.Mock).mockReturnValue(null);
advancedAnalytics.trackCohort('New Users');
expect(localStorage.setItem).toHaveBeenCalledWith('tinytapanalytics_visited', 'true');
expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', expect.objectContaining({
firstVisit: true
}));
});
it('should detect returning visit', () => {
(localStorage.getItem as jest.Mock).mockReturnValue('true');
advancedAnalytics.trackCohort('Returning Users');
expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', expect.objectContaining({
firstVisit: false
}));
});
it('should handle localStorage errors', () => {
(localStorage.getItem as jest.Mock).mockImplementation(() => {
throw new Error('localStorage not available');
});
advancedAnalytics.trackCohort('Users');
expect(mockSdk.track).toHaveBeenCalledWith('cohort_analysis', expect.objectContaining({
firstVisit: false
}));
});
});
describe('trackAttribution', () => {
beforeEach(() => {
// Reset window.location to default
delete (window as any).location;
window.location = {
...window.location,
search: ''
} as any;
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should track attribution from Google', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://www.google.com/search',
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', {
source: 'google',
medium: 'organic',
campaign: null,
referrer: 'https://www.google.com/search',
landingPage: window.location.href,
sessionId: expect.stringContaining('ciq_session_'),
timestamp: expect.any(Number)
});
});
it('should track attribution from Facebook', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://www.facebook.com/post',
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({
source: 'facebook',
medium: 'social'
}));
});
it('should track direct traffic', () => {
Object.defineProperty(document, 'referrer', {
value: '',
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({
source: 'direct',
medium: 'direct'
}));
});
it('should track referral traffic', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://example.com/page',
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({
source: 'referral',
medium: 'referral'
}));
});
it('should extract UTM campaign', () => {
Object.defineProperty(window, 'location', {
value: {
...window.location,
search: '?utm_campaign=summer-sale&utm_medium=email'
},
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({
campaign: 'summer-sale',
medium: 'email'
}));
});
});
describe('trackConversionFunnel', () => {
beforeEach(() => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should track conversion funnel step', () => {
advancedAnalytics.trackConversionFunnel('Checkout', 'Payment Info', 99.99);
expect(mockSdk.track).toHaveBeenCalledWith('conversion_funnel', {
funnelName: 'Checkout',
stepName: 'Payment Info',
value: 99.99,
sessionId: expect.stringContaining('ciq_session_'),
userJourney: expect.any(Array),
timestamp: expect.any(Number)
});
});
it('should track conversion funnel step without value', () => {
advancedAnalytics.trackConversionFunnel('Signup', 'Email');
expect(mockSdk.track).toHaveBeenCalledWith('conversion_funnel', expect.objectContaining({
funnelName: 'Signup',
stepName: 'Email',
value: 0
}));
});
});
describe('trackUserSegment', () => {
beforeEach(() => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should track user segment with properties', () => {
const properties = {
industry: 'technology',
companySize: '50-200'
};
advancedAnalytics.trackUserSegment('Enterprise', properties);
expect(mockSdk.track).toHaveBeenCalledWith('user_segment', {
segment: 'Enterprise',
properties,
sessionId: expect.stringContaining('ciq_session_'),
sessionData: expect.any(Object),
timestamp: expect.any(Number)
});
});
it('should track user segment without properties', () => {
advancedAnalytics.trackUserSegment('Free Users');
expect(mockSdk.track).toHaveBeenCalledWith('user_segment', expect.objectContaining({
segment: 'Free Users',
properties: {}
}));
});
});
describe('user journey', () => {
beforeEach(() => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should add custom events to user journey', () => {
advancedAnalytics.addToUserJourney('button_click', { button: 'signup' });
const journey = advancedAnalytics.getUserJourney();
expect(journey.length).toBe(1);
expect(journey[0].event).toBe('button_click');
expect(journey[0].data).toEqual({ button: 'signup' });
});
it('should increment events count', () => {
advancedAnalytics.addToUserJourney('event1', {});
advancedAnalytics.addToUserJourney('event2', {});
const sessionData = advancedAnalytics.getSessionData();
expect(sessionData.events).toBe(2);
});
it('should limit journey to 50 events', () => {
for (let i = 0; i < 60; i++) {
advancedAnalytics.addToUserJourney(`event${i}`, { index: i });
}
const journey = advancedAnalytics.getUserJourney();
expect(journey.length).toBe(50);
expect(journey[0].data.index).toBe(10); // First 10 should be removed
});
it('should return copy of user journey', () => {
advancedAnalytics.addToUserJourney('test', {});
const journey1 = advancedAnalytics.getUserJourney();
const journey2 = advancedAnalytics.getUserJourney();
expect(journey1).toEqual(journey2);
expect(journey1).not.toBe(journey2); // Different objects
});
});
describe('session data', () => {
beforeEach(() => {
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should return copy of session data', () => {
const sessionData1 = advancedAnalytics.getSessionData();
const sessionData2 = advancedAnalytics.getSessionData();
expect(sessionData1).toEqual(sessionData2);
expect(sessionData1).not.toBe(sessionData2); // Different objects
});
it('should update session duration', () => {
jest.useFakeTimers();
const initialData = advancedAnalytics.getSessionData();
expect(initialData.duration).toBe(0);
jest.advanceTimersByTime(5000);
advancedAnalytics.trackPageView();
const updatedData = advancedAnalytics.getSessionData();
expect(updatedData.duration).toBeGreaterThan(0);
jest.useRealTimers();
});
});
describe('traffic source detection', () => {
beforeEach(() => {
// Reset window.location to default
delete (window as any).location;
window.location = {
...window.location,
search: ''
} as any;
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should detect Bing as source', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://www.bing.com/search',
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({
source: 'bing',
medium: 'organic'
}));
});
it('should detect Yahoo as source', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://search.yahoo.com',
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({
source: 'yahoo',
medium: 'organic'
}));
});
it('should detect Twitter as source', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://twitter.com/post',
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({
source: 'twitter',
medium: 'social'
}));
});
it('should detect LinkedIn as source', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://www.linkedin.com/feed',
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({
source: 'linkedin',
medium: 'social'
}));
});
it('should detect Instagram as source', () => {
Object.defineProperty(document, 'referrer', {
value: 'https://www.instagram.com/post',
configurable: true
});
advancedAnalytics.trackAttribution();
expect(mockSdk.track).toHaveBeenCalledWith('attribution', expect.objectContaining({
source: 'instagram',
medium: 'social'
}));
});
});
describe('session ID generation', () => {
it('should generate unique session IDs', () => {
const analytics1 = new AdvancedAnalytics(mockConfig, mockSdk);
const analytics2 = new AdvancedAnalytics(mockConfig, mockSdk);
const session1 = analytics1.getSessionData();
const session2 = analytics2.getSessionData();
expect(session1.id).not.toBe(session2.id);
expect(session1.id).toMatch(/^ciq_session_\d+_[a-z0-9]+$/);
expect(session2.id).toMatch(/^ciq_session_\d+_[a-z0-9]+$/);
});
});
describe('event listener callbacks', () => {
let eventListeners: Map<string, Function[]>;
let documentListeners: Map<string, Function[]>;
let windowListeners: Map<string, Function[]>;
beforeEach(() => {
eventListeners = new Map();
documentListeners = new Map();
windowListeners = new Map();
// Capture event listeners
jest.spyOn(document, 'addEventListener').mockImplementation((event: string, handler: any) => {
if (!documentListeners.has(event)) {
documentListeners.set(event, []);
}
documentListeners.get(event)!.push(handler);
});
jest.spyOn(window, 'addEventListener').mockImplementation((event: string, handler: any) => {
if (!windowListeners.has(event)) {
windowListeners.set(event, []);
}
windowListeners.get(event)!.push(handler);
});
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should update session activity on visibilitychange when not hidden', () => {
const initialData = advancedAnalytics.getSessionData();
// Mock document as visible
Object.defineProperty(document, 'hidden', {
configurable: true,
value: false
});
// Trigger visibilitychange event
const handlers = documentListeners.get('visibilitychange') || [];
handlers.forEach(handler => handler());
const updatedData = advancedAnalytics.getSessionData();
expect(updatedData.lastActivity).toBeGreaterThanOrEqual(initialData.lastActivity);
});
it('should not update session when document is hidden', () => {
const initialActivity = advancedAnalytics.getSessionData().lastActivity;
// Mock document as hidden
Object.defineProperty(document, 'hidden', {
configurable: true,
value: true
});
// Trigger visibilitychange event
const handlers = documentListeners.get('visibilitychange') || [];
handlers.forEach(handler => handler());
const updatedActivity = advancedAnalytics.getSessionData().lastActivity;
expect(updatedActivity).toBe(initialActivity);
});
it('should update session activity on click', async () => {
const initialActivity = advancedAnalytics.getSessionData().lastActivity;
await new Promise(resolve => setTimeout(resolve, 100));
// Trigger click event
const handlers = documentListeners.get('click') || [];
handlers.forEach(handler => handler());
const updatedActivity = advancedAnalytics.getSessionData().lastActivity;
expect(updatedActivity).toBeGreaterThan(initialActivity);
});
it('should update session activity on scroll', async () => {
const initialActivity = advancedAnalytics.getSessionData().lastActivity;
await new Promise(resolve => setTimeout(resolve, 100));
// Trigger scroll event
const handlers = documentListeners.get('scroll') || [];
handlers.forEach(handler => handler());
const updatedActivity = advancedAnalytics.getSessionData().lastActivity;
expect(updatedActivity).toBeGreaterThan(initialActivity);
});
it('should update session activity on keydown', async () => {
const initialActivity = advancedAnalytics.getSessionData().lastActivity;
await new Promise(resolve => setTimeout(resolve, 100));
// Trigger keydown event
const handlers = documentListeners.get('keydown') || [];
handlers.forEach(handler => handler());
const updatedActivity = advancedAnalytics.getSessionData().lastActivity;
expect(updatedActivity).toBeGreaterThan(initialActivity);
});
it('should update session activity on mousemove', async () => {
const initialActivity = advancedAnalytics.getSessionData().lastActivity;
await new Promise(resolve => setTimeout(resolve, 100));
// Trigger mousemove event
const handlers = documentListeners.get('mousemove') || [];
handlers.forEach(handler => handler());
const updatedActivity = advancedAnalytics.getSessionData().lastActivity;
expect(updatedActivity).toBeGreaterThan(initialActivity);
});
it('should end session on beforeunload', () => {
mockSdk.track.mockClear();
// Trigger beforeunload event
const handlers = windowListeners.get('beforeunload') || [];
handlers.forEach(handler => handler());
expect(mockSdk.track).toHaveBeenCalledWith('session_end', expect.objectContaining({
id: expect.stringContaining('ciq_session_'),
userJourney: expect.any(Array),
endTime: expect.any(Number)
}));
});
});
describe('funnel analysis with elements', () => {
let mutationObserverCallback: Function;
beforeEach(() => {
global.MutationObserver = jest.fn().mockImplementation((callback) => {
mutationObserverCallback = callback;
return {
observe: jest.fn(),
disconnect: jest.fn(),
takeRecords: jest.fn()
};
});
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
});
it('should observe and track funnel step when element appears', () => {
const steps = [{ name: 'Step 1', selector: '.step-1' }];
advancedAnalytics.setupFunnelAnalysis(steps);
// Simulate element appearing in DOM
const mockElement = document.createElement('button');
mockElement.className = 'step-1';
document.body.appendChild(mockElement);
// Simulate querySelector finding the element
jest.spyOn(document, 'querySelector').mockReturnValue(mockElement);
// Trigger MutationObserver callback
mutationObserverCallback();
// Simulate click on the element
mockElement.click();
expect(mockSdk.track).toHaveBeenCalledWith('funnel_step', expect.objectContaining({
stepName: 'Step 1',
stepIndex: 0,
funnelData: expect.objectContaining({
conversions: expect.arrayContaining([1])
})
}));
mockElement.remove();
});
it('should track existing funnel step element', () => {
// Create element before setup
const mockElement = document.createElement('button');
mockElement.className = 'existing-step';
document.body.appendChild(mockElement);
const querySelectorSpy = jest.spyOn(document, 'querySelector');
querySelectorSpy.mockReturnValue(mockElement);
const steps = [{ name: 'Existing Step', selector: '.existing-step' }];
advancedAnalytics.setupFunnelAnalysis(steps);
// Should have added click listener to existing element
mockElement.click();
expect(mockSdk.track).toHaveBeenCalledWith('funnel_step', expect.objectContaining({
stepName: 'Existing Step'
}));
mockElement.remove();
querySelectorSpy.mockRestore();
});
it('should track URL-based funnel step when URL matches', () => {
Object.defineProperty(window, 'location', {
value: {
...window.location,
href: 'https://example.com/checkout'
},
configurable: true
});
const steps = [{ name: 'Checkout', url: '/checkout' }];
advancedAnalytics.setupFunnelAnalysis(steps);
expect(mockSdk.track).toHaveBeenCalledWith('funnel_step', expect.objectContaining({
stepName: 'Checkout',
stepIndex: 0
}));
});
it('should not track URL-based funnel step when URL does not match', () => {
Object.defineProperty(window, 'location', {
value: {
...window.location,
href: 'https://example.com/home'
},
configurable: true
});
mockSdk.track.mockClear();
const steps = [{ name: 'Checkout', url: '/checkout' }];
advancedAnalytics.setupFunnelAnalysis(steps);
const funnelStepCalls = mockSdk.track.mock.calls.filter(
call => call[0] === 'funnel_step'
);
expect(funnelStepCalls.length).toBe(0);
});
it('should not observe funnel step without selector or url', () => {
const steps = [{ name: 'No Selector Step' }];
const mutationCalls = (global.MutationObserver as jest.Mock).mock.calls.length;
advancedAnalytics.setupFunnelAnalysis(steps);
// MutationObserver should not be called for steps without selector or url
expect((global.MutationObserver as jest.Mock).mock.calls.length).toBe(mutationCalls);
});
});
describe('funnel step recording', () => {
beforeEach(() => {
// Reset MutationObserver for funnel tests
global.MutationObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
disconnect: jest.fn(),
takeRecords: jest.fn()
}));
advancedAnalytics = new AdvancedAnalytics(mockConfig, mockSdk);
mockSdk.track.mockClear();
});
it('should calculate dropoff rates', () => {
const mockElement1 = document.createElement('button');
mockElement1.className = 'step-1';
const mockElement2 = document.createElement('button');
mockElement2.className = 'step-2';
document.body.appendChild(mockElement1);
document.body.appendChild(mockElement2);
const querySelectorSpy = jest.spyOn(document, 'querySelector');
querySelectorSpy.mockReturnValueOnce(mockElement1);
querySelectorSpy.mockReturnValueOnce(mockElement2);
const steps = [
{ name: 'Step 1', selector: '.step-1' },
{ name: 'Step 2', selector: '.step-2' }
];
advancedAnalytics.setupFunnelAnalysis(steps);
// Complete step 1 twice
mockElement1.click();
mockElement1.click();
// Complete step 2 once
mockElement2.click();
// Check funnel data - should have tracked 3 funnel_step events
const funnelCalls = mockSdk.track.mock.calls.filter(call => call[0] === 'funnel_step');
expect(funnelCalls.length).toBe(3);
const lastCall = funnelCalls[funnelCalls.length - 1];
const funnelData = lastCall[1].funnelData;
expect(funnelData.conversions[0]).toBe(2);
expect(funnelData.conversions[1]).toBe(1);
expect(funnelData.dropoffs[0]).toBe(1); // 2 - 1 = 1 dropoff
mockElement1.remove();
mockElement2.remove();
querySelectorSpy.mockRestore();
});
it('should calculate conversion rate', () => {
const mockElement1 = document.createElement('button');
mockElement1.className = 'start';
const mockElement2 = document.createElement('button');
mockElement2.className = 'end';
document.body.appendChild(mockElement1);
document.body.appendChild(mockElement2);
const querySelectorSpy = jest.spyOn(document, 'querySelector');
querySelectorSpy.mockReturnValueOnce(mockElement1);
querySelectorSpy.mockReturnValueOnce(mockElement2);
const steps = [
{ name: 'Start', selector: '.start' },
{ name: 'End', selector: '.end' }
];
advancedAnalytics.setupFunnelAnalysis(steps);
// 4 people start
mockElement1.click();
mockElement1.click();
mockElement1.click();
mockElement1.click();
// 1 person completes
mockElement2.click();
// Check conversion rate
const funnelCalls = mockSdk.track.mock.calls.filter(call => call[0] === 'funnel_step');
const lastCall = funnelCalls[funnelCalls.length - 1];
const funnelData = lastCall[1].funnelData;
expect(funnelData.conversionRate).toBe(25); // 1/4 * 100 = 25%
mockElement1.remove();
mockElement2.remove();
querySelectorSpy.mockRestore();
});
});
});