From a8d97cad5d04addb2098ed7da26513d2256f66ea Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Tue, 14 Oct 2025 19:25:35 -0700 Subject: [PATCH 1/2] test: add comprehensive unit tests for IterableInboxMessageList component --- .eslintignore | 3 +- src/core/classes/Iterable.ts | 9 +- .../IterableInboxMessageList.test.tsx | 606 ++++++++++++++++++ 3 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 src/inbox/components/IterableInboxMessageList.test.tsx diff --git a/.eslintignore b/.eslintignore index 81e04e4d2..99285fadd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ docs/ lib/ -node_modules/ \ No newline at end of file +node_modules/ +coverage/ diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 3f56de76f..088a844d0 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -80,8 +80,11 @@ export class Iterable { // Lazy initialization to avoid circular dependency if (!this._inAppManager) { // Import here to avoid circular dependency at module level - // eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-require-imports - const { IterableInAppManager } = require('../../inApp/classes/IterableInAppManager'); + /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ + const { + IterableInAppManager, + } = require('../../inApp/classes/IterableInAppManager'); + /* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ this._inAppManager = new IterableInAppManager(); } return this._inAppManager; @@ -484,8 +487,6 @@ export class Iterable { /** * Launch the application from the background in Android devices. * - * @group Android Only - * * @example * ```typescript * Iterable.wakeApp(); diff --git a/src/inbox/components/IterableInboxMessageList.test.tsx b/src/inbox/components/IterableInboxMessageList.test.tsx new file mode 100644 index 000000000..d78c1338c --- /dev/null +++ b/src/inbox/components/IterableInboxMessageList.test.tsx @@ -0,0 +1,606 @@ +import { render } from '@testing-library/react-native'; +import { IterableInAppMessage, IterableInAppTrigger, IterableInboxMetadata } from '../../inApp/classes'; +import { IterableInAppTriggerType } from '../../inApp/enums'; +import { IterableInboxDataModel } from '../classes'; +import type { IterableInboxCustomizations, IterableInboxImpressionRowInfo, IterableInboxRowViewModel } from '../types'; +import { IterableInboxMessageList } from './IterableInboxMessageList'; + +// Mock the IterableInboxMessageCell component +jest.mock('./IterableInboxMessageCell', () => ({ + IterableInboxMessageCell: ({ rowViewModel, index, last }: { rowViewModel: IterableInboxRowViewModel; index: number; last: boolean }) => { + const { View, Text } = require('react-native'); + return ( + + {rowViewModel.title} + {last ? 'last' : 'not-last'} + + ); + }, +})); + +describe('IterableInboxMessageList', () => { + const mockMessage1 = new IterableInAppMessage( + 'messageId1', + 1, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date('2023-01-01T00:00:00Z'), + undefined, + true, + new IterableInboxMetadata('Title 1', 'Subtitle 1', 'imageUrl1.png'), + undefined, + false, + 0 + ); + + const mockMessage2 = new IterableInAppMessage( + 'messageId2', + 2, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date('2023-01-02T00:00:00Z'), + undefined, + true, + new IterableInboxMetadata('Title 2', 'Subtitle 2', 'imageUrl2.png'), + undefined, + true, + 1 + ); + + const mockRowViewModel1: IterableInboxRowViewModel = { + inAppMessage: mockMessage1, + title: 'Title 1', + subtitle: 'Subtitle 1', + imageUrl: 'imageUrl1.png', + read: false, + createdAt: new Date('2023-01-01T00:00:00Z'), + }; + + const mockRowViewModel2: IterableInboxRowViewModel = { + inAppMessage: mockMessage2, + title: 'Title 2', + subtitle: 'Subtitle 2', + imageUrl: 'imageUrl2.png', + read: true, + createdAt: new Date('2023-01-02T00:00:00Z'), + }; + + const mockDataModel = new IterableInboxDataModel(); + const mockCustomizations: IterableInboxCustomizations = { + messageRow: { + height: 150, + backgroundColor: 'white', + flexDirection: 'row', + paddingTop: 10, + paddingBottom: 10, + }, + title: { + fontSize: 16, + paddingBottom: 5, + }, + body: { + fontSize: 14, + color: 'gray', + paddingBottom: 5, + }, + createdAt: { + fontSize: 12, + color: 'lightgray', + }, + unreadIndicator: { + width: 8, + height: 8, + backgroundColor: 'blue', + borderRadius: 4, + }, + }; + + const defaultProps = { + dataModel: mockDataModel, + rowViewModels: [mockRowViewModel1, mockRowViewModel2], + customizations: mockCustomizations, + messageListItemLayout: jest.fn().mockReturnValue([null, 150]), + deleteRow: jest.fn(), + handleMessageSelect: jest.fn(), + updateVisibleMessageImpressions: jest.fn(), + contentWidth: 300, + isPortrait: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Basic Rendering', () => { + it('should render without crashing with minimal valid props', () => { + expect(() => render()).not.toThrow(); + }); + + it('should render FlatList component', () => { + const { getByTestId } = render(); + // FlatList doesn't have a testID by default, but we can check if it renders + expect(() => getByTestId('message-cell-messageId1')).not.toThrow(); + }); + + it('should render message cells for each row view model', () => { + const { getByTestId } = render(); + + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + expect(getByTestId('message-cell-messageId2')).toBeTruthy(); + }); + + it('should render with empty row view models array', () => { + const propsWithEmptyData = { ...defaultProps, rowViewModels: [] }; + expect(() => render()).not.toThrow(); + }); + + it('should render with single row view model', () => { + const propsWithSingleItem = { ...defaultProps, rowViewModels: [mockRowViewModel1] }; + const { getByTestId } = render(); + + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + }); + + describe('Props Variations', () => { + it('should handle different content widths', () => { + const propsWithDifferentWidth = { ...defaultProps, contentWidth: 600 }; + expect(() => render()).not.toThrow(); + }); + + it('should handle portrait mode', () => { + const portraitProps = { ...defaultProps, isPortrait: true }; + expect(() => render()).not.toThrow(); + }); + + it('should handle landscape mode', () => { + const landscapeProps = { ...defaultProps, isPortrait: false }; + expect(() => render()).not.toThrow(); + }); + + it('should handle zero content width', () => { + const zeroWidthProps = { ...defaultProps, contentWidth: 0 }; + expect(() => render()).not.toThrow(); + }); + + it('should handle negative content width', () => { + const negativeWidthProps = { ...defaultProps, contentWidth: -100 }; + expect(() => render()).not.toThrow(); + }); + + it('should handle very large content width', () => { + const largeWidthProps = { ...defaultProps, contentWidth: 2000 }; + expect(() => render()).not.toThrow(); + }); + }); + + describe('FlatList Functionality', () => { + it('should pass correct data to FlatList', () => { + const { getByTestId } = render(); + + // Verify that both message cells are rendered + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + expect(getByTestId('message-cell-messageId2')).toBeTruthy(); + }); + + it('should use correct keyExtractor', () => { + const { getByTestId } = render(); + + // The keyExtractor should use messageId, which is used in the mock component + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + expect(getByTestId('message-cell-messageId2')).toBeTruthy(); + }); + + it('should pass correct props to message cells', () => { + const { getByTestId } = render(); + + // Check that the first message is not marked as last + expect(getByTestId('message-title-0')).toBeTruthy(); + expect(getByTestId('message-last-0')).toHaveTextContent('not-last'); + + // Check that the second message is marked as last + expect(getByTestId('message-title-1')).toBeTruthy(); + expect(getByTestId('message-last-1')).toHaveTextContent('last'); + }); + + it('should handle single item as last', () => { + const singleItemProps = { ...defaultProps, rowViewModels: [mockRowViewModel1] }; + const { getByTestId } = render(); + + expect(getByTestId('message-last-0')).toHaveTextContent('last'); + }); + }); + + describe('Viewability Configuration', () => { + it('should have correct viewability configuration', () => { + const { getByTestId } = render(); + + // The component should render without errors, indicating viewability config is valid + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + + it('should handle viewability config with minimum view time', () => { + // Test that the component renders with the configured minimumViewTime of 500ms + expect(() => render()).not.toThrow(); + }); + + it('should handle viewability config with item visible percent threshold', () => { + // Test that the component renders with the configured itemVisiblePercentThreshold of 100 + expect(() => render()).not.toThrow(); + }); + + it('should handle viewability config with waitForInteraction false', () => { + // Test that the component renders with waitForInteraction set to false + expect(() => render()).not.toThrow(); + }); + }); + + describe('Impression Tracking', () => { + it('should call updateVisibleMessageImpressions when viewable items change', () => { + const mockUpdateVisibleMessageImpressions = jest.fn(); + const propsWithMockCallback = { + ...defaultProps, + updateVisibleMessageImpressions: mockUpdateVisibleMessageImpressions, + }; + + render(); + + // The callback should be defined and ready to be called + expect(mockUpdateVisibleMessageImpressions).toBeDefined(); + }); + + it('should process view tokens correctly in getRowInfosFromViewTokens', () => { + const mockUpdateVisibleMessageImpressions = jest.fn(); + const propsWithMockCallback = { + ...defaultProps, + updateVisibleMessageImpressions: mockUpdateVisibleMessageImpressions, + }; + + const { getByTestId } = render(); + + // Verify the component renders and can process view tokens + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + + it('should handle inboxSessionItemsChanged callback', () => { + const mockUpdateVisibleMessageImpressions = jest.fn(); + const propsWithMockCallback = { + ...defaultProps, + updateVisibleMessageImpressions: mockUpdateVisibleMessageImpressions, + }; + + const { getByTestId } = render(); + + // The component should render and have the callback ready + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + expect(mockUpdateVisibleMessageImpressions).toBeDefined(); + }); + + it('should handle updateVisibleMessageImpressions with different implementations', () => { + const customUpdateCallback = jest.fn((rowInfos: IterableInboxImpressionRowInfo[]) => { + // Custom implementation + rowInfos.forEach(rowInfo => { + console.log(`Message ${rowInfo.messageId} is visible`); + }); + }); + + const propsWithCustomCallback = { + ...defaultProps, + updateVisibleMessageImpressions: customUpdateCallback, + }; + + expect(() => render()).not.toThrow(); + }); + + it('should handle updateVisibleMessageImpressions with undefined callback', () => { + const propsWithUndefinedCallback = { + ...defaultProps, + updateVisibleMessageImpressions: undefined as unknown as (rowInfos: IterableInboxImpressionRowInfo[]) => void, + }; + + expect(() => render()).not.toThrow(); + }); + }); + + describe('Scroll Behavior', () => { + it('should have scrollEnabled true by default (when not swiping)', () => { + const { getByTestId } = render(); + + // Component should render without errors, indicating scroll is enabled by default + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + + it('should handle swiping state changes', () => { + const { getByTestId } = render(); + + // The component should render and handle swiping state internally + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + }); + + describe('Layout Callback', () => { + it('should handle onLayout callback', () => { + const { getByTestId } = render(); + + // Component should render without errors, indicating onLayout is handled + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + + it('should call recordInteraction on layout', () => { + // We can't directly test the ref behavior, but we can ensure the component renders + expect(() => render()).not.toThrow(); + }); + + it('should handle onLayout callback with FlatList ref', () => { + const { getByTestId } = render(); + + // The component should render and handle the onLayout callback + // The onLayout callback calls flatListRef.current?.recordInteraction() + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + }); + + describe('Function Props', () => { + it('should handle deleteRow function', () => { + const mockDeleteRow = jest.fn(); + const propsWithDeleteRow = { ...defaultProps, deleteRow: mockDeleteRow }; + + expect(() => render()).not.toThrow(); + }); + + it('should handle handleMessageSelect function', () => { + const mockHandleMessageSelect = jest.fn(); + const propsWithHandleMessageSelect = { ...defaultProps, handleMessageSelect: mockHandleMessageSelect }; + + expect(() => render()).not.toThrow(); + }); + + it('should handle messageListItemLayout function', () => { + const mockMessageListItemLayout = jest.fn().mockReturnValue([null, 200]); + const propsWithLayout = { ...defaultProps, messageListItemLayout: mockMessageListItemLayout }; + + expect(() => render()).not.toThrow(); + }); + + it('should handle undefined function props gracefully', () => { + const propsWithUndefinedFunctions = { + ...defaultProps, + deleteRow: undefined as unknown as (messageId: string) => void, + handleMessageSelect: undefined as unknown as (messageId: string, index: number) => void, + messageListItemLayout: undefined as unknown as (last: boolean, rowViewModel: IterableInboxRowViewModel) => [React.ReactElement | null, number], + }; + + expect(() => render()).not.toThrow(); + }); + }); + + describe('Data Model Integration', () => { + it('should work with different data model instances', () => { + const differentDataModel = new IterableInboxDataModel(); + const propsWithDifferentDataModel = { ...defaultProps, dataModel: differentDataModel }; + + expect(() => render()).not.toThrow(); + }); + + it('should handle data model with custom functions', () => { + const customDataModel = new IterableInboxDataModel(); + customDataModel.set( + (message) => message.campaignId > 1, + (msg1, msg2) => msg1.priorityLevel - msg2.priorityLevel, + (message) => message.createdAt?.toISOString() ?? 'No date' + ); + + const propsWithCustomDataModel = { ...defaultProps, dataModel: customDataModel }; + expect(() => render()).not.toThrow(); + }); + }); + + describe('Customizations', () => { + it('should handle different customizations', () => { + const differentCustomizations: IterableInboxCustomizations = { + messageRow: { + height: 200, + backgroundColor: 'red', + }, + title: { + fontSize: 20, + paddingBottom: 10, + }, + }; + + const propsWithDifferentCustomizations = { ...defaultProps, customizations: differentCustomizations }; + expect(() => render()).not.toThrow(); + }); + + it('should handle minimal customizations', () => { + const minimalCustomizations: IterableInboxCustomizations = { + messageRow: { + height: 100, + }, + }; + + const propsWithMinimalCustomizations = { ...defaultProps, customizations: minimalCustomizations }; + expect(() => render()).not.toThrow(); + }); + }); + + describe('Edge Cases', () => { + it('should handle null row view models', () => { + const propsWithNullData = { ...defaultProps, rowViewModels: null as unknown as IterableInboxRowViewModel[] }; + expect(() => render()).not.toThrow(); + }); + + it('should handle undefined row view models', () => { + const propsWithUndefinedData = { ...defaultProps, rowViewModels: undefined as unknown as IterableInboxRowViewModel[] }; + expect(() => render()).not.toThrow(); + }); + + it('should handle row view models with missing properties', () => { + const incompleteRowViewModel = { + inAppMessage: mockMessage1, + title: 'Incomplete Title', + // Missing other properties + } as IterableInboxRowViewModel; + + const propsWithIncompleteData = { ...defaultProps, rowViewModels: [incompleteRowViewModel] }; + expect(() => render()).not.toThrow(); + }); + + it('should handle very large number of row view models', () => { + const largeRowViewModels = Array.from({ length: 1000 }, (_, i) => ({ + inAppMessage: new IterableInAppMessage( + `messageId${i}`, + i, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + undefined, + true, + new IterableInboxMetadata(`Title ${i}`, `Subtitle ${i}`, `imageUrl${i}.png`), + undefined, + false, + i + ), + title: `Title ${i}`, + subtitle: `Subtitle ${i}`, + imageUrl: `imageUrl${i}.png`, + read: false, + createdAt: new Date(), + })); + + const propsWithLargeData = { ...defaultProps, rowViewModels: largeRowViewModels }; + expect(() => render()).not.toThrow(); + }); + + it('should handle row view models with special characters in message IDs', () => { + const specialMessage = new IterableInAppMessage( + 'message-id-with-special-chars_123@test.com#special', + 1, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + undefined, + true, + new IterableInboxMetadata('Special Title', 'Special Subtitle', 'special.png'), + undefined, + false, + 0 + ); + + const specialRowViewModel: IterableInboxRowViewModel = { + inAppMessage: specialMessage, + title: 'Special Title', + subtitle: 'Special Subtitle', + imageUrl: 'special.png', + read: false, + createdAt: new Date(), + }; + + const propsWithSpecialData = { ...defaultProps, rowViewModels: [specialRowViewModel] }; + expect(() => render()).not.toThrow(); + }); + + it('should handle row view models with unicode characters', () => { + const unicodeMessage = new IterableInAppMessage( + '测试消息ID_🚀_ñáéíóú', + 1, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + undefined, + true, + new IterableInboxMetadata('测试标题', '测试副标题', '测试图片.png'), + undefined, + false, + 0 + ); + + const unicodeRowViewModel: IterableInboxRowViewModel = { + inAppMessage: unicodeMessage, + title: '测试标题', + subtitle: '测试副标题', + imageUrl: '测试图片.png', + read: false, + createdAt: new Date(), + }; + + const propsWithUnicodeData = { ...defaultProps, rowViewModels: [unicodeRowViewModel] }; + expect(() => render()).not.toThrow(); + }); + }); + + describe('Component State Management', () => { + it('should manage swiping state internally', () => { + const { getByTestId } = render(); + + // Component should render and manage swiping state + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + + it('should maintain FlatList ref', () => { + const { getByTestId } = render(); + + // Component should render and maintain ref + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + }); + + describe('Performance Considerations', () => { + it('should handle rapid prop changes', () => { + const { rerender, getByTestId } = render(); + + // Change props rapidly + const newProps1 = { ...defaultProps, contentWidth: 400 }; + const newProps2 = { ...defaultProps, isPortrait: false }; + const newProps3 = { ...defaultProps, contentWidth: 500, isPortrait: true }; + + expect(() => { + rerender(); + rerender(); + rerender(); + }).not.toThrow(); + + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + + it('should handle memory efficiently with large datasets', () => { + const largeDataset = Array.from({ length: 100 }, (_, i) => ({ + inAppMessage: new IterableInAppMessage( + `largeMessageId${i}`, + i, + new IterableInAppTrigger(IterableInAppTriggerType.immediate), + new Date(), + undefined, + true, + new IterableInboxMetadata(`Large Title ${i}`, `Large Subtitle ${i}`, `largeImage${i}.png`), + undefined, + false, + i + ), + title: `Large Title ${i}`, + subtitle: `Large Subtitle ${i}`, + imageUrl: `largeImage${i}.png`, + read: false, + createdAt: new Date(), + })); + + const propsWithLargeDataset = { ...defaultProps, rowViewModels: largeDataset }; + expect(() => render()).not.toThrow(); + }); + }); + + describe('Integration with IterableInboxMessageCell', () => { + it('should pass all required props to message cells', () => { + const { getByTestId } = render(); + + // Verify that message cells receive the correct props by checking their rendering + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + expect(getByTestId('message-cell-messageId2')).toBeTruthy(); + }); + + it('should handle message cell prop changes', () => { + const { rerender, getByTestId } = render(); + + // Change props that affect message cells + const newProps = { ...defaultProps, contentWidth: 600, isPortrait: false }; + rerender(); + + expect(getByTestId('message-cell-messageId1')).toBeTruthy(); + }); + }); +}); From 4f9fd7d4a83c900a4955fb19325ea8e52ff2d19d Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Fri, 17 Oct 2025 13:28:14 -0700 Subject: [PATCH 2/2] test: enhance IterableInboxMessageList tests with viewability handling --- .../IterableInboxMessageList.test.tsx | 491 +++++++++++++++--- 1 file changed, 410 insertions(+), 81 deletions(-) diff --git a/src/inbox/components/IterableInboxMessageList.test.tsx b/src/inbox/components/IterableInboxMessageList.test.tsx index d78c1338c..766fc90b0 100644 --- a/src/inbox/components/IterableInboxMessageList.test.tsx +++ b/src/inbox/components/IterableInboxMessageList.test.tsx @@ -1,18 +1,36 @@ import { render } from '@testing-library/react-native'; -import { IterableInAppMessage, IterableInAppTrigger, IterableInboxMetadata } from '../../inApp/classes'; +import { + IterableInAppMessage, + IterableInAppTrigger, + IterableInboxMetadata, +} from '../../inApp/classes'; import { IterableInAppTriggerType } from '../../inApp/enums'; import { IterableInboxDataModel } from '../classes'; -import type { IterableInboxCustomizations, IterableInboxImpressionRowInfo, IterableInboxRowViewModel } from '../types'; +import type { + IterableInboxCustomizations, + IterableInboxImpressionRowInfo, + IterableInboxRowViewModel, +} from '../types'; import { IterableInboxMessageList } from './IterableInboxMessageList'; // Mock the IterableInboxMessageCell component jest.mock('./IterableInboxMessageCell', () => ({ - IterableInboxMessageCell: ({ rowViewModel, index, last }: { rowViewModel: IterableInboxRowViewModel; index: number; last: boolean }) => { + IterableInboxMessageCell: ({ + rowViewModel, + index, + last, + }: { + rowViewModel: IterableInboxRowViewModel; + index: number; + last: boolean; + }) => { const { View, Text } = require('react-native'); return ( {rowViewModel.title} - {last ? 'last' : 'not-last'} + + {last ? 'last' : 'not-last'} + ); }, @@ -111,17 +129,23 @@ describe('IterableInboxMessageList', () => { describe('Basic Rendering', () => { it('should render without crashing with minimal valid props', () => { - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should render FlatList component', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // FlatList doesn't have a testID by default, but we can check if it renders expect(() => getByTestId('message-cell-messageId1')).not.toThrow(); }); it('should render message cells for each row view model', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); expect(getByTestId('message-cell-messageId1')).toBeTruthy(); expect(getByTestId('message-cell-messageId2')).toBeTruthy(); @@ -129,12 +153,19 @@ describe('IterableInboxMessageList', () => { it('should render with empty row view models array', () => { const propsWithEmptyData = { ...defaultProps, rowViewModels: [] }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should render with single row view model', () => { - const propsWithSingleItem = { ...defaultProps, rowViewModels: [mockRowViewModel1] }; - const { getByTestId } = render(); + const propsWithSingleItem = { + ...defaultProps, + rowViewModels: [mockRowViewModel1], + }; + const { getByTestId } = render( + + ); expect(getByTestId('message-cell-messageId1')).toBeTruthy(); }); @@ -143,38 +174,52 @@ describe('IterableInboxMessageList', () => { describe('Props Variations', () => { it('should handle different content widths', () => { const propsWithDifferentWidth = { ...defaultProps, contentWidth: 600 }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle portrait mode', () => { const portraitProps = { ...defaultProps, isPortrait: true }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle landscape mode', () => { const landscapeProps = { ...defaultProps, isPortrait: false }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle zero content width', () => { const zeroWidthProps = { ...defaultProps, contentWidth: 0 }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle negative content width', () => { const negativeWidthProps = { ...defaultProps, contentWidth: -100 }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle very large content width', () => { const largeWidthProps = { ...defaultProps, contentWidth: 2000 }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); }); describe('FlatList Functionality', () => { it('should pass correct data to FlatList', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // Verify that both message cells are rendered expect(getByTestId('message-cell-messageId1')).toBeTruthy(); @@ -182,7 +227,9 @@ describe('IterableInboxMessageList', () => { }); it('should use correct keyExtractor', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // The keyExtractor should use messageId, which is used in the mock component expect(getByTestId('message-cell-messageId1')).toBeTruthy(); @@ -190,7 +237,9 @@ describe('IterableInboxMessageList', () => { }); it('should pass correct props to message cells', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // Check that the first message is not marked as last expect(getByTestId('message-title-0')).toBeTruthy(); @@ -202,8 +251,13 @@ describe('IterableInboxMessageList', () => { }); it('should handle single item as last', () => { - const singleItemProps = { ...defaultProps, rowViewModels: [mockRowViewModel1] }; - const { getByTestId } = render(); + const singleItemProps = { + ...defaultProps, + rowViewModels: [mockRowViewModel1], + }; + const { getByTestId } = render( + + ); expect(getByTestId('message-last-0')).toHaveTextContent('last'); }); @@ -211,7 +265,9 @@ describe('IterableInboxMessageList', () => { describe('Viewability Configuration', () => { it('should have correct viewability configuration', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // The component should render without errors, indicating viewability config is valid expect(getByTestId('message-cell-messageId1')).toBeTruthy(); @@ -219,17 +275,23 @@ describe('IterableInboxMessageList', () => { it('should handle viewability config with minimum view time', () => { // Test that the component renders with the configured minimumViewTime of 500ms - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle viewability config with item visible percent threshold', () => { // Test that the component renders with the configured itemVisiblePercentThreshold of 100 - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle viewability config with waitForInteraction false', () => { // Test that the component renders with waitForInteraction set to false - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); }); @@ -254,7 +316,9 @@ describe('IterableInboxMessageList', () => { updateVisibleMessageImpressions: mockUpdateVisibleMessageImpressions, }; - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // Verify the component renders and can process view tokens expect(getByTestId('message-cell-messageId1')).toBeTruthy(); @@ -267,7 +331,9 @@ describe('IterableInboxMessageList', () => { updateVisibleMessageImpressions: mockUpdateVisibleMessageImpressions, }; - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // The component should render and have the callback ready expect(getByTestId('message-cell-messageId1')).toBeTruthy(); @@ -275,41 +341,53 @@ describe('IterableInboxMessageList', () => { }); it('should handle updateVisibleMessageImpressions with different implementations', () => { - const customUpdateCallback = jest.fn((rowInfos: IterableInboxImpressionRowInfo[]) => { - // Custom implementation - rowInfos.forEach(rowInfo => { - console.log(`Message ${rowInfo.messageId} is visible`); - }); - }); + const customUpdateCallback = jest.fn( + (rowInfos: IterableInboxImpressionRowInfo[]) => { + // Custom implementation + rowInfos.forEach((rowInfo) => { + console.log(`Message ${rowInfo.messageId} is visible`); + }); + } + ); const propsWithCustomCallback = { ...defaultProps, updateVisibleMessageImpressions: customUpdateCallback, }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle updateVisibleMessageImpressions with undefined callback', () => { const propsWithUndefinedCallback = { ...defaultProps, - updateVisibleMessageImpressions: undefined as unknown as (rowInfos: IterableInboxImpressionRowInfo[]) => void, + updateVisibleMessageImpressions: undefined as unknown as ( + rowInfos: IterableInboxImpressionRowInfo[] + ) => void, }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); }); describe('Scroll Behavior', () => { it('should have scrollEnabled true by default (when not swiping)', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // Component should render without errors, indicating scroll is enabled by default expect(getByTestId('message-cell-messageId1')).toBeTruthy(); }); it('should handle swiping state changes', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // The component should render and handle swiping state internally expect(getByTestId('message-cell-messageId1')).toBeTruthy(); @@ -318,7 +396,9 @@ describe('IterableInboxMessageList', () => { describe('Layout Callback', () => { it('should handle onLayout callback', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // Component should render without errors, indicating onLayout is handled expect(getByTestId('message-cell-messageId1')).toBeTruthy(); @@ -326,11 +406,15 @@ describe('IterableInboxMessageList', () => { it('should call recordInteraction on layout', () => { // We can't directly test the ref behavior, but we can ensure the component renders - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle onLayout callback with FlatList ref', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // The component should render and handle the onLayout callback // The onLayout callback calls flatListRef.current?.recordInteraction() @@ -343,41 +427,66 @@ describe('IterableInboxMessageList', () => { const mockDeleteRow = jest.fn(); const propsWithDeleteRow = { ...defaultProps, deleteRow: mockDeleteRow }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle handleMessageSelect function', () => { const mockHandleMessageSelect = jest.fn(); - const propsWithHandleMessageSelect = { ...defaultProps, handleMessageSelect: mockHandleMessageSelect }; + const propsWithHandleMessageSelect = { + ...defaultProps, + handleMessageSelect: mockHandleMessageSelect, + }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle messageListItemLayout function', () => { const mockMessageListItemLayout = jest.fn().mockReturnValue([null, 200]); - const propsWithLayout = { ...defaultProps, messageListItemLayout: mockMessageListItemLayout }; + const propsWithLayout = { + ...defaultProps, + messageListItemLayout: mockMessageListItemLayout, + }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle undefined function props gracefully', () => { const propsWithUndefinedFunctions = { ...defaultProps, deleteRow: undefined as unknown as (messageId: string) => void, - handleMessageSelect: undefined as unknown as (messageId: string, index: number) => void, - messageListItemLayout: undefined as unknown as (last: boolean, rowViewModel: IterableInboxRowViewModel) => [React.ReactElement | null, number], + handleMessageSelect: undefined as unknown as ( + messageId: string, + index: number + ) => void, + messageListItemLayout: undefined as unknown as ( + last: boolean, + rowViewModel: IterableInboxRowViewModel + ) => [React.ReactElement | null, number], }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); }); describe('Data Model Integration', () => { it('should work with different data model instances', () => { const differentDataModel = new IterableInboxDataModel(); - const propsWithDifferentDataModel = { ...defaultProps, dataModel: differentDataModel }; + const propsWithDifferentDataModel = { + ...defaultProps, + dataModel: differentDataModel, + }; - expect(() => render()).not.toThrow(); + expect(() => + render() + ).not.toThrow(); }); it('should handle data model with custom functions', () => { @@ -388,8 +497,13 @@ describe('IterableInboxMessageList', () => { (message) => message.createdAt?.toISOString() ?? 'No date' ); - const propsWithCustomDataModel = { ...defaultProps, dataModel: customDataModel }; - expect(() => render()).not.toThrow(); + const propsWithCustomDataModel = { + ...defaultProps, + dataModel: customDataModel, + }; + expect(() => + render() + ).not.toThrow(); }); }); @@ -406,8 +520,15 @@ describe('IterableInboxMessageList', () => { }, }; - const propsWithDifferentCustomizations = { ...defaultProps, customizations: differentCustomizations }; - expect(() => render()).not.toThrow(); + const propsWithDifferentCustomizations = { + ...defaultProps, + customizations: differentCustomizations, + }; + expect(() => + render( + + ) + ).not.toThrow(); }); it('should handle minimal customizations', () => { @@ -417,20 +538,35 @@ describe('IterableInboxMessageList', () => { }, }; - const propsWithMinimalCustomizations = { ...defaultProps, customizations: minimalCustomizations }; - expect(() => render()).not.toThrow(); + const propsWithMinimalCustomizations = { + ...defaultProps, + customizations: minimalCustomizations, + }; + expect(() => + render() + ).not.toThrow(); }); }); describe('Edge Cases', () => { it('should handle null row view models', () => { - const propsWithNullData = { ...defaultProps, rowViewModels: null as unknown as IterableInboxRowViewModel[] }; - expect(() => render()).not.toThrow(); + const propsWithNullData = { + ...defaultProps, + rowViewModels: null as unknown as IterableInboxRowViewModel[], + }; + expect(() => + render() + ).not.toThrow(); }); it('should handle undefined row view models', () => { - const propsWithUndefinedData = { ...defaultProps, rowViewModels: undefined as unknown as IterableInboxRowViewModel[] }; - expect(() => render()).not.toThrow(); + const propsWithUndefinedData = { + ...defaultProps, + rowViewModels: undefined as unknown as IterableInboxRowViewModel[], + }; + expect(() => + render() + ).not.toThrow(); }); it('should handle row view models with missing properties', () => { @@ -440,8 +576,13 @@ describe('IterableInboxMessageList', () => { // Missing other properties } as IterableInboxRowViewModel; - const propsWithIncompleteData = { ...defaultProps, rowViewModels: [incompleteRowViewModel] }; - expect(() => render()).not.toThrow(); + const propsWithIncompleteData = { + ...defaultProps, + rowViewModels: [incompleteRowViewModel], + }; + expect(() => + render() + ).not.toThrow(); }); it('should handle very large number of row view models', () => { @@ -453,7 +594,11 @@ describe('IterableInboxMessageList', () => { new Date(), undefined, true, - new IterableInboxMetadata(`Title ${i}`, `Subtitle ${i}`, `imageUrl${i}.png`), + new IterableInboxMetadata( + `Title ${i}`, + `Subtitle ${i}`, + `imageUrl${i}.png` + ), undefined, false, i @@ -465,8 +610,13 @@ describe('IterableInboxMessageList', () => { createdAt: new Date(), })); - const propsWithLargeData = { ...defaultProps, rowViewModels: largeRowViewModels }; - expect(() => render()).not.toThrow(); + const propsWithLargeData = { + ...defaultProps, + rowViewModels: largeRowViewModels, + }; + expect(() => + render() + ).not.toThrow(); }); it('should handle row view models with special characters in message IDs', () => { @@ -477,7 +627,11 @@ describe('IterableInboxMessageList', () => { new Date(), undefined, true, - new IterableInboxMetadata('Special Title', 'Special Subtitle', 'special.png'), + new IterableInboxMetadata( + 'Special Title', + 'Special Subtitle', + 'special.png' + ), undefined, false, 0 @@ -492,8 +646,13 @@ describe('IterableInboxMessageList', () => { createdAt: new Date(), }; - const propsWithSpecialData = { ...defaultProps, rowViewModels: [specialRowViewModel] }; - expect(() => render()).not.toThrow(); + const propsWithSpecialData = { + ...defaultProps, + rowViewModels: [specialRowViewModel], + }; + expect(() => + render() + ).not.toThrow(); }); it('should handle row view models with unicode characters', () => { @@ -519,21 +678,30 @@ describe('IterableInboxMessageList', () => { createdAt: new Date(), }; - const propsWithUnicodeData = { ...defaultProps, rowViewModels: [unicodeRowViewModel] }; - expect(() => render()).not.toThrow(); + const propsWithUnicodeData = { + ...defaultProps, + rowViewModels: [unicodeRowViewModel], + }; + expect(() => + render() + ).not.toThrow(); }); }); describe('Component State Management', () => { it('should manage swiping state internally', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // Component should render and manage swiping state expect(getByTestId('message-cell-messageId1')).toBeTruthy(); }); it('should maintain FlatList ref', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // Component should render and maintain ref expect(getByTestId('message-cell-messageId1')).toBeTruthy(); @@ -542,12 +710,18 @@ describe('IterableInboxMessageList', () => { describe('Performance Considerations', () => { it('should handle rapid prop changes', () => { - const { rerender, getByTestId } = render(); + const { rerender, getByTestId } = render( + + ); // Change props rapidly const newProps1 = { ...defaultProps, contentWidth: 400 }; const newProps2 = { ...defaultProps, isPortrait: false }; - const newProps3 = { ...defaultProps, contentWidth: 500, isPortrait: true }; + const newProps3 = { + ...defaultProps, + contentWidth: 500, + isPortrait: true, + }; expect(() => { rerender(); @@ -567,7 +741,11 @@ describe('IterableInboxMessageList', () => { new Date(), undefined, true, - new IterableInboxMetadata(`Large Title ${i}`, `Large Subtitle ${i}`, `largeImage${i}.png`), + new IterableInboxMetadata( + `Large Title ${i}`, + `Large Subtitle ${i}`, + `largeImage${i}.png` + ), undefined, false, i @@ -579,14 +757,21 @@ describe('IterableInboxMessageList', () => { createdAt: new Date(), })); - const propsWithLargeDataset = { ...defaultProps, rowViewModels: largeDataset }; - expect(() => render()).not.toThrow(); + const propsWithLargeDataset = { + ...defaultProps, + rowViewModels: largeDataset, + }; + expect(() => + render() + ).not.toThrow(); }); }); describe('Integration with IterableInboxMessageCell', () => { it('should pass all required props to message cells', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + ); // Verify that message cells receive the correct props by checking their rendering expect(getByTestId('message-cell-messageId1')).toBeTruthy(); @@ -594,13 +779,157 @@ describe('IterableInboxMessageList', () => { }); it('should handle message cell prop changes', () => { - const { rerender, getByTestId } = render(); + const { rerender, getByTestId } = render( + + ); // Change props that affect message cells - const newProps = { ...defaultProps, contentWidth: 600, isPortrait: false }; + const newProps = { + ...defaultProps, + contentWidth: 600, + isPortrait: false, + }; rerender(); expect(getByTestId('message-cell-messageId1')).toBeTruthy(); }); }); + + describe('Viewable Items Change Handling', () => { + it('should call updateVisibleMessageImpressions when onViewableItemsChanged is triggered', () => { + const mockUpdateVisibleMessageImpressions = jest.fn(); + const propsWithMockCallback = { + ...defaultProps, + updateVisibleMessageImpressions: mockUpdateVisibleMessageImpressions, + }; + + // Render the component + const { UNSAFE_root } = render( + + ); + + // Find the FlatList component + const flatListInstance = UNSAFE_root.findByType( + require('react-native').FlatList + ); + + // Create mock ViewTokens that simulate what FlatList provides + const mockViewToken1 = { + item: mockRowViewModel1, + index: 0, + isViewable: true, + key: 'messageId1', + }; + + const mockViewToken2 = { + item: mockRowViewModel2, + index: 1, + isViewable: true, + key: 'messageId2', + }; + + // Simulate the FlatList calling onViewableItemsChanged + const mockInfo = { + viewableItems: [mockViewToken1, mockViewToken2], + changed: [mockViewToken1, mockViewToken2], + }; + + // Call the onViewableItemsChanged prop directly + flatListInstance.props.onViewableItemsChanged(mockInfo); + + // Verify updateVisibleMessageImpressions was called + expect(mockUpdateVisibleMessageImpressions).toHaveBeenCalledTimes(1); + + // Verify it was called with the correct structure + expect(mockUpdateVisibleMessageImpressions).toHaveBeenCalledWith([ + { + messageId: 'messageId1', + silentInbox: expect.any(Boolean), + }, + { + messageId: 'messageId2', + silentInbox: expect.any(Boolean), + }, + ]); + }); + + it('should process view tokens and extract messageId and silentInbox', () => { + const mockUpdateVisibleMessageImpressions = jest.fn(); + const propsWithMockCallback = { + ...defaultProps, + updateVisibleMessageImpressions: mockUpdateVisibleMessageImpressions, + }; + + const { UNSAFE_root } = render( + + ); + const flatListInstance = UNSAFE_root.findByType( + require('react-native').FlatList + ); + + const mockViewToken = { + item: mockRowViewModel1, + index: 0, + isViewable: true, + key: 'messageId1', + }; + + const mockInfo = { + viewableItems: [mockViewToken], + changed: [mockViewToken], + }; + + flatListInstance.props.onViewableItemsChanged(mockInfo); + + expect(mockUpdateVisibleMessageImpressions).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + messageId: 'messageId1', + silentInbox: expect.any(Boolean), + }), + ]) + ); + }); + + it('should handle empty viewableItems array', () => { + const mockUpdateVisibleMessageImpressions = jest.fn(); + const propsWithMockCallback = { + ...defaultProps, + updateVisibleMessageImpressions: mockUpdateVisibleMessageImpressions, + }; + + const { UNSAFE_root } = render( + + ); + const flatListInstance = UNSAFE_root.findByType( + require('react-native').FlatList + ); + + const mockInfo = { + viewableItems: [], + changed: [], + }; + + flatListInstance.props.onViewableItemsChanged(mockInfo); + + expect(mockUpdateVisibleMessageImpressions).toHaveBeenCalledWith([]); + }); + + it('should have correct viewability configuration', () => { + const { UNSAFE_root } = render( + + ); + const flatListInstance = UNSAFE_root.findByType( + require('react-native').FlatList + ); + + const viewabilityConfig = flatListInstance.props.viewabilityConfig; + + expect(viewabilityConfig).toEqual({ + minimumViewTime: 500, + itemVisiblePercentThreshold: 100, + waitForInteraction: false, + }); + }); + }); });