import '../../rtc/__tests__/mocks/webrtc.mocks'; import { describe, expect, it } from 'vitest'; import { CallState } from '../../store'; import { VisibilityState } from '../../types'; import { TrackType } from '../../gen/video/sfu/models/models'; import { noopComparator } from '../../sorting'; import { watchParticipantJoined, watchParticipantLeft, watchParticipantUpdated, watchTrackPublished, watchTrackUnpublished, } from '../participant'; describe('Participant events', () => { describe('participantJoined / participantLeft / participantUpdated', () => { it('adds and removes the participant to the list of participants', () => { const state = new CallState(); state.setSortParticipantsBy(noopComparator()); const onParticipantJoined = watchParticipantJoined(state); const onParticipantLeft = watchParticipantLeft(state); const onParticipantUpdated = watchParticipantUpdated(state); onParticipantJoined({ // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', roles: ['user'], }, }); expect(state.participants).toEqual([ { userId: 'user-id', sessionId: 'session-id', roles: ['user'], viewportVisibilityState: { videoTrack: VisibilityState.UNKNOWN, screenShareTrack: VisibilityState.UNKNOWN, }, }, ]); onParticipantUpdated({ // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', roles: ['host'], }, }); expect(state.participants).toEqual([ { userId: 'user-id', sessionId: 'session-id', roles: ['host'], viewportVisibilityState: { videoTrack: VisibilityState.UNKNOWN, screenShareTrack: VisibilityState.UNKNOWN, }, }, ]); onParticipantLeft({ // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', }, }); expect(state.participants).toEqual([]); }); it('sets a server-side pin when isPinned is true', () => { const state = new CallState(); state.setSortParticipantsBy(noopComparator()); const onParticipantJoined = watchParticipantJoined(state); const now = Date.now(); onParticipantJoined({ // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', }, isPinned: true, }); const participant = state.findParticipantBySessionId('session-id'); expect(participant?.pin).toBeDefined(); expect(participant?.pin?.isLocalPin).toBe(false); expect(participant?.pin?.pinnedAt).toBeGreaterThanOrEqual(now); }); it('does not set a pin when isPinned is false', () => { const state = new CallState(); state.setSortParticipantsBy(noopComparator()); const onParticipantJoined = watchParticipantJoined(state); onParticipantJoined({ // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', }, isPinned: false, }); const participant = state.findParticipantBySessionId('session-id'); expect(participant?.pin).toBeUndefined(); }); }); describe('orphaned tracks reconciliation', () => { it('participantJoined should reconcile orphaned tracks if any', () => { const state = new CallState(); const mediaStream = new MediaStream(); state.registerOrphanedTrack({ trackLookupPrefix: 'track-lookup-prefix', trackType: TrackType.VIDEO, track: mediaStream, id: mediaStream.id, }); const onParticipantJoined = watchParticipantJoined(state); onParticipantJoined({ // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', trackLookupPrefix: 'track-lookup-prefix', }, }); const p = state.findParticipantBySessionId('session-id'); expect(p).toBeDefined(); expect(p?.videoStream).toBe(mediaStream); expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0); }); it('trackPublished should reconcile orphaned tracks if any', () => { const state = new CallState(); const mediaStream = new MediaStream(); state.registerOrphanedTrack({ trackLookupPrefix: 'track-lookup-prefix', trackType: TrackType.AUDIO, track: mediaStream, id: mediaStream.id, }); const onTrackPublished = watchTrackPublished(state); onTrackPublished({ // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', trackLookupPrefix: 'track-lookup-prefix', }, }); const p = state.findParticipantBySessionId('session-id'); expect(p).toBeDefined(); expect(p?.audioStream).toBe(mediaStream); expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0); }); it('trackUnpublished should reconcile orphaned tracks if any', () => { const state = new CallState(); const mediaStream = new MediaStream(); state.registerOrphanedTrack({ trackLookupPrefix: 'track-lookup-prefix', trackType: TrackType.SCREEN_SHARE, track: mediaStream, id: mediaStream.id, }); const onTrackUnPublished = watchTrackUnpublished(state); onTrackUnPublished({ // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', trackLookupPrefix: 'track-lookup-prefix', }, }); const p = state.findParticipantBySessionId('session-id'); expect(p).toBeDefined(); expect(p?.screenShareStream).toBe(mediaStream); expect(state.takeOrphanedTracks('track-lookup-prefix')).toHaveLength(0); }); }); describe('trackPublished', () => { it('updates the participant track list', () => { const state = new CallState(); const handler = watchTrackPublished(state); // @ts-expect-error setup one participant state.setParticipants([{ sessionId: 'session-id', publishedTracks: [] }]); // @ts-expect-error incomplete data handler({ sessionId: 'session-id', type: TrackType.VIDEO, }); expect(state.findParticipantBySessionId('session-id')).toEqual({ sessionId: 'session-id', publishedTracks: [TrackType.VIDEO], }); }); it('adds the participant to the list of participants if provided', () => { const state = new CallState(); const handler = watchTrackPublished(state); handler({ sessionId: 'session-id', type: TrackType.VIDEO, // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', publishedTracks: [TrackType.VIDEO], }, }); expect(state.findParticipantBySessionId('session-id')).toEqual({ userId: 'user-id', sessionId: 'session-id', publishedTracks: [TrackType.VIDEO], }); }); it('updates the participant info if the provided participant already exists', () => { const state = new CallState(); const handler = watchTrackPublished(state); state.setParticipants([ // @ts-expect-error setup one participant { sessionId: 'session-id', publishedTracks: [], screenShareDimension: { width: 100, height: 100 }, }, ]); handler({ sessionId: 'session-id', type: TrackType.VIDEO, // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', publishedTracks: [TrackType.VIDEO, TrackType.AUDIO], }, }); expect(state.findParticipantBySessionId('session-id')).toEqual({ userId: 'user-id', sessionId: 'session-id', publishedTracks: [TrackType.VIDEO, TrackType.AUDIO], screenShareDimension: { width: 100, height: 100 }, }); }); }); describe('trackUnpublished', () => { it('updates the participant track list', () => { const state = new CallState(); const handler = watchTrackUnpublished(state); state.setParticipants([ // @ts-expect-error setup one participant { sessionId: 'session-id', publishedTracks: [TrackType.VIDEO] }, ]); // @ts-expect-error incomplete data handler({ sessionId: 'session-id', type: TrackType.VIDEO }); expect(state.findParticipantBySessionId('session-id')).toEqual({ sessionId: 'session-id', publishedTracks: [], }); }); it('resets the paused track list if the track is unpublished', () => { const state = new CallState(); state.setParticipants([ // @ts-expect-error setup one participant { sessionId: 'session-id', publishedTracks: [TrackType.VIDEO, TrackType.SCREEN_SHARE], pausedTracks: [TrackType.VIDEO, TrackType.SCREEN_SHARE], }, ]); const trackUnpublish = watchTrackUnpublished(state); // @ts-expect-error incomplete data trackUnpublish({ sessionId: 'session-id', type: TrackType.VIDEO }); expect(state.findParticipantBySessionId('session-id')).toEqual({ sessionId: 'session-id', publishedTracks: [TrackType.SCREEN_SHARE], pausedTracks: [TrackType.SCREEN_SHARE], }); // @ts-expect-error incomplete data trackUnpublish({ sessionId: 'session-id', type: TrackType.SCREEN_SHARE }); expect(state.findParticipantBySessionId('session-id')).toEqual({ sessionId: 'session-id', publishedTracks: [], pausedTracks: [], }); }); it('resets the paused track list if the track is unpublished on full participant update', () => { const state = new CallState(); state.setParticipants([ // @ts-expect-error setup one participant { sessionId: 'session-id', publishedTracks: [TrackType.VIDEO, TrackType.SCREEN_SHARE], pausedTracks: [TrackType.VIDEO, TrackType.SCREEN_SHARE], }, ]); const trackUnpublished = watchTrackUnpublished(state); trackUnpublished({ sessionId: 'session-id', type: TrackType.VIDEO, // @ts-expect-error incomplete data participant: { publishedTracks: [TrackType.SCREEN_SHARE] }, }); expect(state.findParticipantBySessionId('session-id')).toEqual({ sessionId: 'session-id', publishedTracks: [TrackType.SCREEN_SHARE], pausedTracks: [TrackType.SCREEN_SHARE], }); trackUnpublished({ sessionId: 'session-id', type: TrackType.SCREEN_SHARE, // @ts-expect-error incomplete data participant: { publishedTracks: [] }, }); expect(state.findParticipantBySessionId('session-id')).toEqual({ sessionId: 'session-id', publishedTracks: [], pausedTracks: [], }); }); it('adds the participant to the list of participants if provided', () => { const state = new CallState(); const handler = watchTrackUnpublished(state); handler({ sessionId: 'session-id', type: TrackType.AUDIO, // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', publishedTracks: [TrackType.VIDEO, TrackType.SCREEN_SHARE], }, }); expect(state.findParticipantBySessionId('session-id')).toEqual({ userId: 'user-id', sessionId: 'session-id', publishedTracks: [TrackType.VIDEO, TrackType.SCREEN_SHARE], }); }); it('updates the participant info if the provided participant already exists', () => { const state = new CallState(); const handler = watchTrackUnpublished(state); state.setParticipants([ // @ts-expect-error setup one participant { userId: 'user-id', sessionId: 'session-id', publishedTracks: [TrackType.AUDIO, TrackType.VIDEO], videoDimension: { width: 10, height: 10 }, }, ]); handler({ sessionId: 'session-id', type: TrackType.AUDIO, // @ts-expect-error incomplete data participant: { userId: 'user-id', sessionId: 'session-id', trackLookupPrefix: 'track-lookup-prefix', publishedTracks: [TrackType.VIDEO], }, }); expect(state.findParticipantBySessionId('session-id')).toEqual({ userId: 'user-id', sessionId: 'session-id', trackLookupPrefix: 'track-lookup-prefix', publishedTracks: [TrackType.VIDEO], videoDimension: { width: 10, height: 10 }, }); }); }); });