stream-chat-react-native-core
Version:
The official React Native and Expo components for Stream Chat, a service for building chat applications
683 lines (510 loc) • 21.2 kB
text/typescript
import { NativeHandlers } from '../../native';
import { ONE_SECOND_IN_MILLISECONDS } from '../../utils/constants';
import { INITIAL_VIDEO_PLAYER_STATE, VideoPlayer, VideoPlayerOptions } from '../video-player';
import { VideoPlayerPool } from '../video-player-pool';
// Mock the native module
jest.mock('../../native', () => ({
NativeHandlers: {
SDK: '',
},
}));
const createMockPlayerRef = () => ({
currentTime: 0,
pause: jest.fn(),
play: jest.fn(),
seek: jest.fn(),
seekBy: jest.fn(),
});
const createMockPool = (): jest.Mocked<VideoPlayerPool> =>
({
notifyPaused: jest.fn(),
requestPlay: jest.fn(),
}) as unknown as jest.Mocked<VideoPlayerPool>;
describe('VideoPlayer', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset SDK to default (non-Expo) for most tests
(NativeHandlers as { SDK: string }).SDK = '';
});
describe('INITIAL_VIDEO_PLAYER_STATE', () => {
it('should have correct initial values', () => {
expect(INITIAL_VIDEO_PLAYER_STATE).toEqual({
currentPlaybackRate: 1,
duration: 0,
isPlaying: false,
position: 0,
progress: 0,
playbackRates: [1, 1.5, 2],
});
});
});
describe('constructor', () => {
it('should initialize with default state when no autoPlay option', () => {
const options: VideoPlayerOptions = { id: 'test-player' };
const player = new VideoPlayer(options);
expect(player.id).toBe('test-player');
expect(player.isPlaying).toBe(false);
expect(player.duration).toBe(0);
expect(player.position).toBe(0);
expect(player.progress).toBe(0);
});
it('should initialize with isPlaying=true when autoPlay is true', () => {
const options: VideoPlayerOptions = { autoPlay: true, id: 'autoplay-player' };
const player = new VideoPlayer(options);
expect(player.isPlaying).toBe(true);
});
it('should initialize with isPlaying=false when autoPlay is false', () => {
const options: VideoPlayerOptions = { autoPlay: false, id: 'no-autoplay-player' };
const player = new VideoPlayer(options);
expect(player.isPlaying).toBe(false);
});
it('should store options', () => {
const options: VideoPlayerOptions = { autoPlay: true, id: 'options-player' };
const player = new VideoPlayer(options);
expect(player.options).toBe(options);
});
it('should initialize playerRef as null', () => {
const player = new VideoPlayer({ id: 'test-player' });
expect(player.playerRef).toBeNull();
});
it('should detect Expo SDK', () => {
(NativeHandlers as { SDK: string }).SDK = 'stream-chat-expo';
const player = new VideoPlayer({ id: 'expo-player' });
// isExpoCLI is private, but we can test its effect through seek behavior
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.duration = 10000;
player.seek(5000);
// Expo performs an absolute seek by assigning currentTime (in seconds)
expect(mockRef.currentTime).toBe(5);
expect(mockRef.seek).not.toHaveBeenCalled();
});
it('should detect non-Expo SDK', () => {
(NativeHandlers as { SDK: string }).SDK = 'stream-chat-react-native';
const player = new VideoPlayer({ id: 'rn-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.duration = 10000;
player.seek(5000);
// Non-Expo uses seek instead of seekBy
expect(mockRef.seek).toHaveBeenCalled();
expect(mockRef.seekBy).not.toHaveBeenCalled();
});
});
describe('initPlayer', () => {
it('should set playerRef when provided', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
expect(player.playerRef).toBe(mockRef);
});
it('should set playerRef to null when not provided', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.initPlayer({});
expect(player.playerRef).toBeNull();
});
});
describe('id getter', () => {
it('should return the player id', () => {
const player = new VideoPlayer({ id: 'unique-id' });
expect(player.id).toBe('unique-id');
});
});
describe('isPlaying getter and setter', () => {
it('should get isPlaying from state', () => {
const player = new VideoPlayer({ autoPlay: true, id: 'test-player' });
expect(player.isPlaying).toBe(true);
});
it('should set isPlaying in state', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.isPlaying = true;
expect(player.state.getLatestValue().isPlaying).toBe(true);
player.isPlaying = false;
expect(player.state.getLatestValue().isPlaying).toBe(false);
});
});
describe('duration getter and setter', () => {
it('should get duration from state', () => {
const player = new VideoPlayer({ id: 'test-player' });
expect(player.duration).toBe(0);
});
it('should set duration in state', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.duration = 5000;
expect(player.state.getLatestValue().duration).toBe(5000);
expect(player.duration).toBe(5000);
});
});
describe('position getter and setter', () => {
it('should get position from state', () => {
const player = new VideoPlayer({ id: 'test-player' });
expect(player.position).toBe(0);
});
it('should set position and calculate progress in state', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.duration = 10000;
player.position = 5000;
expect(player.state.getLatestValue().position).toBe(5000);
expect(player.state.getLatestValue().progress).toBe(0.5);
});
it('should handle position when duration is 0', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.position = 5000;
expect(player.state.getLatestValue().position).toBe(5000);
expect(player.state.getLatestValue().progress).toBe(Infinity);
});
});
describe('progress getter and setter', () => {
it('should get progress from state', () => {
const player = new VideoPlayer({ id: 'test-player' });
expect(player.progress).toBe(0);
});
it('should set progress and calculate position in state', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.duration = 10000;
player.progress = 0.75;
expect(player.state.getLatestValue().progress).toBe(0.75);
expect(player.state.getLatestValue().position).toBe(7500);
});
it('should handle progress when duration is 0', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.progress = 0.5;
expect(player.state.getLatestValue().progress).toBe(0.5);
expect(player.state.getLatestValue().position).toBe(0);
});
});
describe('pool setter', () => {
it('should set pool reference', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockPool = createMockPool();
player.pool = mockPool;
// Test pool is set by checking play() behavior
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.play();
expect(mockPool.requestPlay).toHaveBeenCalledWith('test-player');
});
});
describe('play', () => {
it('should call requestPlay on pool when pool is set', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockPool = createMockPool();
player.pool = mockPool;
player.play();
expect(mockPool.requestPlay).toHaveBeenCalledWith('test-player');
});
it('should call play on playerRef when available', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.play();
expect(mockRef.play).toHaveBeenCalled();
});
it('should set isPlaying to true in state', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.play();
expect(player.state.getLatestValue().isPlaying).toBe(true);
});
it('should not throw when no pool or playerRef', () => {
const player = new VideoPlayer({ id: 'test-player' });
expect(() => player.play()).not.toThrow();
expect(player.isPlaying).toBe(true);
});
it('should not call playerRef.play when play method is undefined', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = { pause: jest.fn() }; // No play method
player.initPlayer({ playerRef: mockRef as never });
expect(() => player.play()).not.toThrow();
expect(player.isPlaying).toBe(true);
});
});
describe('pause', () => {
it('should call pause on playerRef when available', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.pause();
expect(mockRef.pause).toHaveBeenCalled();
});
it('should set isPlaying to false in state', () => {
const player = new VideoPlayer({ autoPlay: true, id: 'test-player' });
player.pause();
expect(player.state.getLatestValue().isPlaying).toBe(false);
});
it('should call notifyPaused on pool when pool is set', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockPool = createMockPool();
player.pool = mockPool;
player.pause();
expect(mockPool.notifyPaused).toHaveBeenCalled();
});
it('should not throw when no pool or playerRef', () => {
const player = new VideoPlayer({ id: 'test-player' });
expect(() => player.pause()).not.toThrow();
expect(player.isPlaying).toBe(false);
});
it('should not call playerRef.pause when pause method is undefined', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = { play: jest.fn() }; // No pause method
player.initPlayer({ playerRef: mockRef as never });
expect(() => player.pause()).not.toThrow();
});
});
describe('toggle', () => {
it('should call pause when currently playing', () => {
const player = new VideoPlayer({ autoPlay: true, id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.toggle();
expect(mockRef.pause).toHaveBeenCalled();
expect(player.isPlaying).toBe(false);
});
it('should call play when currently paused', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.toggle();
expect(mockRef.play).toHaveBeenCalled();
expect(player.isPlaying).toBe(true);
});
it('should toggle state correctly multiple times', () => {
const player = new VideoPlayer({ id: 'test-player' });
expect(player.isPlaying).toBe(false);
player.toggle();
expect(player.isPlaying).toBe(true);
player.toggle();
expect(player.isPlaying).toBe(false);
player.toggle();
expect(player.isPlaying).toBe(true);
});
});
describe('seek', () => {
it('should update position state', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.duration = 10000;
player.seek(5000);
expect(player.position).toBe(5000);
expect(player.progress).toBe(0.5);
});
it('should call seek on playerRef for non-Expo SDK', () => {
(NativeHandlers as { SDK: string }).SDK = 'stream-chat-react-native';
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.duration = 10000;
player.seek(5000);
expect(mockRef.seek).toHaveBeenCalledWith(5000 / ONE_SECOND_IN_MILLISECONDS);
expect(mockRef.seekBy).not.toHaveBeenCalled();
});
it('should set currentTime on playerRef for Expo SDK (absolute seek)', () => {
(NativeHandlers as { SDK: string }).SDK = 'stream-chat-expo';
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.duration = 10000;
player.seek(5000);
// seekBy is relative and must not be used to jump to a timestamp
expect(mockRef.currentTime).toBe(5000 / ONE_SECOND_IN_MILLISECONDS);
expect(mockRef.seekBy).not.toHaveBeenCalled();
expect(mockRef.seek).not.toHaveBeenCalled();
});
it('should not throw when playerRef has no seek methods', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = {};
player.initPlayer({ playerRef: mockRef as never });
player.duration = 10000;
expect(() => player.seek(5000)).not.toThrow();
expect(player.position).toBe(5000);
});
it('should not throw when no playerRef', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.duration = 10000;
expect(() => player.seek(5000)).not.toThrow();
expect(player.position).toBe(5000);
});
it('should convert milliseconds to seconds correctly', () => {
(NativeHandlers as { SDK: string }).SDK = 'stream-chat-react-native';
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.duration = 10000;
player.seek(2500);
expect(mockRef.seek).toHaveBeenCalledWith(2.5);
});
});
describe('stop', () => {
it('should seek to 0 and pause', () => {
const player = new VideoPlayer({ autoPlay: true, id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.duration = 10000;
player.position = 5000;
player.stop();
expect(player.position).toBe(0);
expect(player.isPlaying).toBe(false);
expect(mockRef.pause).toHaveBeenCalled();
});
it('should work without playerRef', () => {
const player = new VideoPlayer({ autoPlay: true, id: 'test-player' });
player.duration = 10000;
player.position = 5000;
player.stop();
expect(player.position).toBe(0);
expect(player.isPlaying).toBe(false);
});
});
describe('onRemove', () => {
it('should pause playerRef if available', () => {
const player = new VideoPlayer({ autoPlay: true, id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.onRemove();
expect(mockRef.pause).toHaveBeenCalled();
});
it('should set playerRef to null', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.onRemove();
expect(player.playerRef).toBeNull();
});
it('should reset state to initial values', () => {
const player = new VideoPlayer({ autoPlay: true, id: 'test-player' });
player.duration = 10000;
player.position = 5000;
player.onRemove();
expect(player.state.getLatestValue()).toEqual(INITIAL_VIDEO_PLAYER_STATE);
});
it('should not throw when no playerRef', () => {
const player = new VideoPlayer({ id: 'test-player' });
expect(() => player.onRemove()).not.toThrow();
});
it('should not throw when playerRef has no pause method', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = { play: jest.fn() };
player.initPlayer({ playerRef: mockRef as never });
expect(() => player.onRemove()).not.toThrow();
expect(player.playerRef).toBeNull();
});
});
describe('state reactivity', () => {
it('should notify subscribers when isPlaying changes', () => {
const player = new VideoPlayer({ id: 'test-player' });
const callback = jest.fn();
player.state.subscribeWithSelector(
(state) => ({ isPlaying: state.isPlaying }),
({ isPlaying }) => callback(isPlaying),
);
player.play();
expect(callback).toHaveBeenCalledWith(true);
});
it('should notify subscribers when duration changes', () => {
const player = new VideoPlayer({ id: 'test-player' });
const callback = jest.fn();
player.state.subscribeWithSelector(
(state) => ({ duration: state.duration }),
({ duration }) => callback(duration),
);
player.duration = 5000;
expect(callback).toHaveBeenCalledWith(5000);
});
it('should notify subscribers when position changes', () => {
const player = new VideoPlayer({ id: 'test-player' });
const callback = jest.fn();
player.duration = 10000;
player.state.subscribeWithSelector(
(state) => ({ position: state.position }),
({ position }) => callback(position),
);
player.position = 2500;
expect(callback).toHaveBeenCalledWith(2500);
});
it('should notify subscribers when progress changes', () => {
const player = new VideoPlayer({ id: 'test-player' });
const callback = jest.fn();
player.duration = 10000;
player.state.subscribeWithSelector(
(state) => ({ progress: state.progress }),
({ progress }) => callback(progress),
);
player.progress = 0.5;
expect(callback).toHaveBeenCalledWith(0.5);
});
});
describe('edge cases', () => {
it('should handle play/pause cycle correctly', () => {
const player = new VideoPlayer({ id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.play();
expect(player.isPlaying).toBe(true);
expect(mockRef.play).toHaveBeenCalledTimes(1);
player.pause();
expect(player.isPlaying).toBe(false);
expect(mockRef.pause).toHaveBeenCalledTimes(1);
player.play();
expect(player.isPlaying).toBe(true);
expect(mockRef.play).toHaveBeenCalledTimes(2);
});
it('should handle seek during playback', () => {
const player = new VideoPlayer({ autoPlay: true, id: 'test-player' });
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.duration = 10000;
player.seek(7500);
expect(player.position).toBe(7500);
expect(player.progress).toBe(0.75);
expect(player.isPlaying).toBe(true); // Should remain playing
});
it('should handle zero duration gracefully', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.position = 1000;
expect(player.progress).toBe(Infinity);
player.progress = 0.5;
expect(player.position).toBe(0); // 0.5 * 0 = 0
});
it('should handle negative position values', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.duration = 10000;
player.position = -1000;
expect(player.position).toBe(-1000);
expect(player.progress).toBe(-0.1);
});
it('should handle progress values greater than 1', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.duration = 10000;
player.progress = 1.5;
expect(player.progress).toBe(1.5);
expect(player.position).toBe(15000);
});
it('should handle multiple state updates in sequence', () => {
const player = new VideoPlayer({ id: 'test-player' });
player.duration = 10000;
player.position = 2500;
player.isPlaying = true;
player.progress = 0.75;
const state = player.state.getLatestValue();
expect(state.duration).toBe(10000);
expect(state.progress).toBe(0.75);
expect(state.position).toBe(7500); // progress setter updates position
expect(state.isPlaying).toBe(true);
});
it('should maintain state consistency after onRemove', () => {
const player = new VideoPlayer({ autoPlay: true, id: 'test-player' });
player.duration = 10000;
player.position = 5000;
const mockRef = createMockPlayerRef();
player.initPlayer({ playerRef: mockRef as never });
player.onRemove();
const state = player.state.getLatestValue();
expect(state).toEqual(INITIAL_VIDEO_PLAYER_STATE);
expect(player.playerRef).toBeNull();
// Should be able to reinitialize
const newMockRef = createMockPlayerRef();
player.initPlayer({ playerRef: newMockRef as never });
expect(player.playerRef).toBe(newMockRef);
});
});
});