diff --git a/src/components/mobile/MobileQuizManager/QuizCarousel.tsx b/src/components/mobile/MobileQuizManager/QuizCarousel.tsx index feeb5fe2..c966f556 100644 --- a/src/components/mobile/MobileQuizManager/QuizCarousel.tsx +++ b/src/components/mobile/MobileQuizManager/QuizCarousel.tsx @@ -37,19 +37,7 @@ const QuizCarousel = ({ } }, [activeIndex, currentQuestionIndex]); - const trackScrollAnalytics = (event: any) => { - const offsetX = event.nativeEvent.contentOffset.x; - const index = Math.round(offsetX / SCREEN_WIDTH); - - trackEvent(AnalyticsEvent.PERFORMANCE_METRIC, { - event_category: 'high_frequency', - event_name: 'quiz_carousel_scroll', - offsetX: Math.round(offsetX), - index, - }); - isScrollingRef.current = true; - }; const getItemLayout = useCallback( (_: ArrayLike | null | undefined, index: number) => ({ @@ -64,12 +52,20 @@ const QuizCarousel = ({ (event: { nativeEvent: { contentOffset: { x: number } } }) => { isScrollingRef.current = false; const index = Math.round(event.nativeEvent.contentOffset.x / SCREEN_WIDTH); + + trackEvent(AnalyticsEvent.PERFORMANCE_METRIC, { + event_category: 'high_frequency', + event_name: 'quiz_carousel_scroll', + offsetX: Math.round(event.nativeEvent.contentOffset.x), + index, + }); + if (index < 0 || index >= questions.length || index === activeIndex) return; setActiveIndex(index); onQuestionChange(index); }, - [activeIndex, onQuestionChange, questions.length] + [activeIndex, onQuestionChange, questions.length, trackEvent] ); const renderItem = useCallback( @@ -99,12 +95,10 @@ const QuizCarousel = ({ horizontal pagingEnabled showsHorizontalScrollIndicator={false} - onScroll={trackScrollAnalytics} onScrollBeginDrag={() => { isScrollingRef.current = true; }} onMomentumScrollEnd={handleMomentumScrollEnd} - scrollEventThrottle={16} decelerationRate="fast" snapToInterval={SCREEN_WIDTH} snapToAlignment="center" diff --git a/src/components/mobile/MobileQuizManager/__tests__/QuizCarousel.test.tsx b/src/components/mobile/MobileQuizManager/__tests__/QuizCarousel.test.tsx new file mode 100644 index 00000000..a33f49c9 --- /dev/null +++ b/src/components/mobile/MobileQuizManager/__tests__/QuizCarousel.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import QuizCarousel from '../QuizCarousel'; +import { useAnalytics } from '../../../../hooks/useAnalytics'; + +jest.mock('../../../../hooks/useAnalytics', () => ({ + useAnalytics: jest.fn(), +})); + +describe('QuizCarousel Analytics', () => { + const mockTrackEvent = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useAnalytics as jest.Mock).mockReturnValue({ + trackEvent: mockTrackEvent, + }); + }); + + it('fires analytics only once per swipe (onMomentumScrollEnd), not on every frame', () => { + const questions = [ + { id: '1', text: 'Q1', type: 'multiple-choice', options: [] }, + { id: '2', text: 'Q2', type: 'multiple-choice', options: [] }, + ] as any; + + const { getByTestId } = render( + + ); + + const flatList = getByTestId('QuizCarouselList'); + + // Simulate drag start + fireEvent(flatList, 'scrollBeginDrag'); + + // Simulate scroll end (momentum end) simulating scrolling to 2nd item (index 1) + // Assuming SCREEN_WIDTH is some value, let's pass a simulated offset + fireEvent(flatList, 'momentumScrollEnd', { + nativeEvent: { + contentOffset: { x: 400 }, // some arbitrary width indicating swipe + }, + }); + + expect(mockTrackEvent).toHaveBeenCalledTimes(1); + expect(mockTrackEvent).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ + event_category: 'high_frequency', + event_name: 'quiz_carousel_scroll', + })); + }); +}); diff --git a/src/services/socket/__tests__/backoff.test.ts b/src/services/socket/__tests__/backoff.test.ts new file mode 100644 index 00000000..a4ad7f3c --- /dev/null +++ b/src/services/socket/__tests__/backoff.test.ts @@ -0,0 +1,63 @@ +import { io } from 'socket.io-client'; +import SocketService from '../index'; +import { useSocketStore } from '../../../store'; + +jest.mock('socket.io-client', () => { + const mSocket = { + connected: false, + on: jest.fn(), + emit: jest.fn(), + disconnect: jest.fn(), + connect: jest.fn(), + }; + return { + io: jest.fn(() => mSocket), + }; +}); + +jest.mock('../../../store', () => ({ + useSocketStore: { + getState: jest.fn(() => ({ + setReconnectAttempts: jest.fn(), + setConnectionFailed: jest.fn(), + resetConnection: jest.fn(), + })), + }, +})); + +describe('Socket Reconnection Backoff', () => { + it('configures exponential backoff correctly', () => { + SocketService.connect(); + + expect(io).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ + reconnectionAttempts: 10, + reconnectionDelay: 1000, + reconnectionDelayMax: 30000, + randomizationFactor: 0.2, // Gives 0.8 to 1.2 multiplier + })); + }); + + it('calculates backoff delay sequence according to specifications', () => { + // This tests the math logic that socket.io-client implements internally + // delay = Math.min(1000 * 2^attempt, 30000) * (0.8 + Math.random() * 0.4) + const getBaseDelay = (attempt: number) => Math.min(1000 * Math.pow(2, attempt), 30000); + + const attempt0 = getBaseDelay(0); + expect(attempt0).toBe(1000); + + const attempt1 = getBaseDelay(1); + expect(attempt1).toBe(2000); + + const attempt2 = getBaseDelay(2); + expect(attempt2).toBe(4000); + + const attempt3 = getBaseDelay(3); + expect(attempt3).toBe(8000); + + const attempt4 = getBaseDelay(4); + expect(attempt4).toBe(16000); + + const attempt5 = getBaseDelay(5); + expect(attempt5).toBe(30000); // Capped at 30s + }); +}); diff --git a/src/services/socket/index.ts b/src/services/socket/index.ts index d66a492f..41c485f0 100644 --- a/src/services/socket/index.ts +++ b/src/services/socket/index.ts @@ -1,5 +1,6 @@ import { io, Socket } from 'socket.io-client'; +import { useSocketStore } from '../../store'; import { decodeBinaryMessage, encodeBinaryMessage } from './binaryProtocol'; import { getEnv } from '../../config'; import { appLogger } from '../../utils/logger'; @@ -7,9 +8,6 @@ import syncEntityManager from '../sync/syncEntityManager'; import type { ConflictResolutionStrategy, VersionedSyncMessage } from '../sync/types'; -const RECONNECTION_ATTEMPTS = 10; -const RECONNECTION_DELAY_MS = 1_000; -const RECONNECTION_DELAY_MAX_MS = 30_000; const HEARTBEAT_INTERVAL_MS = 30_000; const HEARTBEAT_TIMEOUT_MS = 5_000; @@ -18,6 +16,7 @@ const BACKOFF_DELAYS = [1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000]; class SocketService { private socket: Socket | null = null; + private stableConnectionTimeout?: NodeJS.Timeout; private heartbeatTimer: ReturnType | null = null; private pongTimeoutTimer: ReturnType | null = null; private backoffIndex = 0; @@ -43,12 +42,24 @@ class SocketService { if (transport) { appLogger.debug(`Socket active transport: ${transport.name}`); } + + // Reset connection state after stable 60s connection + if (this.stableConnectionTimeout) { + clearTimeout(this.stableConnectionTimeout); + } + this.stableConnectionTimeout = setTimeout(() => { + useSocketStore.getState().resetConnection(); + }, 60000); + this.backoffIndex = 0; this.startHeartbeat(); }); this.socket.on('disconnect', (reason: string) => { appLogger.warn('Socket disconnected:', reason); + if (this.stableConnectionTimeout) { + clearTimeout(this.stableConnectionTimeout); + } this.stopHeartbeat(); if (!this.intentionalDisconnect && reason !== 'io client disconnect') { this.scheduleReconnect(); @@ -118,12 +129,16 @@ class SocketService { appLogger.info(`Socket reconnecting in ${actualDelay}ms (backoff index: ${this.backoffIndex})`); + useSocketStore.getState().setReconnectAttempts(this.backoffIndex + 1); + this.reconnectTimer = setTimeout(() => { if (this.socket) { this.socket.connect(); } if (this.backoffIndex < BACKOFF_DELAYS.length - 1) { this.backoffIndex++; + } else { + useSocketStore.getState().setConnectionFailed(true); } }, actualDelay); } diff --git a/src/store/index.ts b/src/store/index.ts index db3097dc..e3663d31 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -146,4 +146,5 @@ export * from './metricsStore'; export * from './notificationStore'; export * from './reviewStore'; export * from './selectors'; +export * from './socketStore'; export * from './syncStore'; diff --git a/src/store/socketStore.ts b/src/store/socketStore.ts new file mode 100644 index 00000000..1c6db37b --- /dev/null +++ b/src/store/socketStore.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand'; + +interface SocketState { + reconnectAttempts: number; + connectionFailed: boolean; + setReconnectAttempts: (attempts: number) => void; + setConnectionFailed: (failed: boolean) => void; + resetConnection: () => void; +} + +export const useSocketStore = create((set) => ({ + reconnectAttempts: 0, + connectionFailed: false, + setReconnectAttempts: (attempts) => set({ reconnectAttempts: attempts }), + setConnectionFailed: (failed) => set({ connectionFailed: failed }), + resetConnection: () => set({ reconnectAttempts: 0, connectionFailed: false }), +}));