diff --git a/backend/signal/src/events/events.gateway.spec.ts b/backend/signal/src/events/events.gateway.spec.ts index b83401b2..a308ad59 100644 --- a/backend/signal/src/events/events.gateway.spec.ts +++ b/backend/signal/src/events/events.gateway.spec.ts @@ -202,32 +202,27 @@ describe('EventsGateway', () => { }); }); - describe('signal 이벤트 (offer, answer, candidate) on', () => { - it('offer 이벤트: 방에 offer이벤트에 받은 sdp 보냄', () => { - gateway.handleOfferEvent(hostSocket, ['sdp' as any, roomMock.roomId]); - - expectEmitToRoom(hostSocket, roomMock.roomId, 'offer', 'sdp'); - expect(loggerService.debug).toHaveBeenCalledWith( - `🚀 Offer Received from ${hostSocket.id}`, - ); - }); - - it('answer 이벤트: 방에 answer이벤트에 받은 sdp 보냄', () => { - gateway.handleAnswerEvent(guestSocket, ['sdp' as any, roomMock.roomId]); + describe('connection 이벤트 (offer, answer, candidate) ', () => { + it('description(offer, answer)이 있을 때: 방에 description 보냄', () => { + gateway.handleConnectionEvent(hostSocket, { + description: 'description' as any, + roomName: roomMock.roomId, + }); - expectEmitToRoom(guestSocket, roomMock.roomId, 'answer', 'sdp'); - expect(loggerService.debug).toHaveBeenCalledWith( - `🚀 Answer Received from ${guestSocket.id}`, - ); + expectEmitToRoom(hostSocket, roomMock.roomId, 'connection', { + description: 'description', + }); }); - it('candidate 이벤트: 방에 candidate이벤트에 받은 candidate 보냄', () => { - gateway.handleCandidateEvent(guestSocket, ['candidate' as any, 'roomId']); + it('candidate가 있을 때: 방에 candidate 보냄', () => { + gateway.handleConnectionEvent(hostSocket, { + candidate: 'candidate' as any, + roomName: roomMock.roomId, + }); - expectEmitToRoom(guestSocket, 'roomId', 'candidate', 'candidate'); - expect(loggerService.debug).toHaveBeenCalledWith( - `🚀 Candidate Received from ${guestSocket.id}`, - ); + expectEmitToRoom(hostSocket, roomMock.roomId, 'connection', { + candidate: 'candidate', + }); }); }); diff --git a/backend/signal/src/events/events.gateway.ts b/backend/signal/src/events/events.gateway.ts index 6bc8bbab..b08824be 100644 --- a/backend/signal/src/events/events.gateway.ts +++ b/backend/signal/src/events/events.gateway.ts @@ -132,31 +132,30 @@ export class EventsGateway this.eventEmit(socket, 'joinRoomSuccess', roomId); } - @SubscribeMessage('offer') - handleOfferEvent( + @SubscribeMessage('connection') + handleConnectionEvent( socket: Socket, - [sdp, roomName]: [RTCSessionDescription, string], + { + description, + candidate, + roomName, + }: { + description?: RTCSessionDescription; + candidate?: RTCIceCandidate; + roomName: string; + }, ) { - this.logger.debug(`🚀 Offer Received from ${socket.id}`); - this.eventEmitToRoom(socket, roomName, 'offer', sdp); - } - - @SubscribeMessage('answer') - handleAnswerEvent( - socket: Socket, - [sdp, roomName]: [RTCSessionDescription, string], - ) { - this.logger.debug(`🚀 Answer Received from ${socket.id}`); - this.eventEmitToRoom(socket, roomName, 'answer', sdp); - } - - @SubscribeMessage('candidate') - handleCandidateEvent( - socket: Socket, - [candidate, roomName]: [RTCIceCandidate, string], - ) { - this.logger.debug(`🚀 Candidate Received from ${socket.id}`); - this.eventEmitToRoom(socket, roomName, 'candidate', candidate); + try { + if (description) { + this.logger.debug(`🚀 ${description.type} Received from ${socket.id}`); + this.eventEmitToRoom(socket, roomName, 'connection', { description }); + } else if (candidate) { + this.logger.debug(`🚀 Candidate Received from ${socket.id}`); + this.eventEmitToRoom(socket, roomName, 'connection', { candidate }); + } + } catch (error) { + this.logger.error(`🚀 Error in handleMessageEvent : ${error}`); + } } @SubscribeMessage('checkRoomExist') @@ -178,7 +177,7 @@ export class EventsGateway public eventEmitToRoom( socket: Socket, roomName: string, - event: HumanServerEvent, + event: HumanServerEvent | 'connection', ...args: any[] ) { socket.to(roomName).emit(event, ...args); diff --git a/frontend/__mocks__/zustand.ts b/frontend/__mocks__/zustand.ts index e6692b69..bb6d3e7a 100644 --- a/frontend/__mocks__/zustand.ts +++ b/frontend/__mocks__/zustand.ts @@ -36,6 +36,13 @@ export const createStore = ((stateCreator: zustand.StateCreator) => { return typeof stateCreator === 'function' ? createStoreUncurried(stateCreator) : createStoreUncurried; }) as typeof zustand.createStore; +window.MediaStream = vi.fn().mockReturnValue({ + getTracks: vi.fn().mockReturnValue([ + { enabled: true, id: 'test' }, + { enabled: true, id: 'test' }, + ]), +}); + // reset all stores after each test run afterEach(() => { act(() => { diff --git a/frontend/package.json b/frontend/package.json index 8dc97511..b0690b92 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -69,7 +69,7 @@ "typescript": "^5.2.2", "vite": "^5.0.11", "vite-tsconfig-paths": "^4.2.3", - "vitest": "^1.2.0" + "vitest": "^1.3.0" }, "msw": { "workerDirectory": "public" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index accb0148..ad4cdab5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -75,7 +75,7 @@ devDependencies: version: 9.3.4 '@testing-library/jest-dom': specifier: ^6.2.0 - version: 6.2.0(vitest@1.2.0) + version: 6.2.0(vitest@1.3.0) '@testing-library/react': specifier: ^14.1.2 version: 14.1.2(react-dom@18.2.0)(react@18.2.0) @@ -167,8 +167,8 @@ devDependencies: specifier: ^4.2.3 version: 4.2.3(typescript@5.2.2)(vite@5.0.11) vitest: - specifier: ^1.2.0 - version: 1.2.0(jsdom@23.2.0) + specifier: ^1.3.0 + version: 1.3.0(jsdom@23.2.0) packages: @@ -4140,7 +4140,7 @@ packages: pretty-format: 27.5.1 dev: true - /@testing-library/jest-dom@6.2.0(vitest@1.2.0): + /@testing-library/jest-dom@6.2.0(vitest@1.3.0): resolution: {integrity: sha512-+BVQlJ9cmEn5RDMUS8c2+TU6giLvzaHZ8sU/x0Jj7fk+6/46wPdwlgOPcpxS17CjcanBi/3VmGMqVr2rmbUmNw==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} peerDependencies: @@ -4166,7 +4166,7 @@ packages: dom-accessibility-api: 0.6.3 lodash: 4.17.21 redent: 3.0.0 - vitest: 1.2.0(jsdom@23.2.0) + vitest: 1.3.0(jsdom@23.2.0) dev: true /@testing-library/react-hooks@8.0.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): @@ -4765,38 +4765,38 @@ packages: - supports-color dev: true - /@vitest/expect@1.2.0: - resolution: {integrity: sha512-H+2bHzhyvgp32o7Pgj2h9RTHN0pgYaoi26Oo3mE+dCi1PAqV31kIIVfTbqMO3Bvshd5mIrJLc73EwSRrbol9Lw==} + /@vitest/expect@1.3.0: + resolution: {integrity: sha512-7bWt0vBTZj08B+Ikv70AnLRicohYwFgzNjFqo9SxxqHHxSlUJGSXmCRORhOnRMisiUryKMdvsi1n27Bc6jL9DQ==} dependencies: - '@vitest/spy': 1.2.0 - '@vitest/utils': 1.2.0 + '@vitest/spy': 1.3.0 + '@vitest/utils': 1.3.0 chai: 4.4.1 dev: true - /@vitest/runner@1.2.0: - resolution: {integrity: sha512-vaJkDoQaNUTroT70OhM0NPznP7H3WyRwt4LvGwCVYs/llLaqhoSLnlIhUClZpbF5RgAee29KRcNz0FEhYcgxqA==} + /@vitest/runner@1.3.0: + resolution: {integrity: sha512-1Jb15Vo/Oy7mwZ5bXi7zbgszsdIBNjc4IqP8Jpr/8RdBC4nF1CTzIAn2dxYvpF1nGSseeL39lfLQ2uvs5u1Y9A==} dependencies: - '@vitest/utils': 1.2.0 + '@vitest/utils': 1.3.0 p-limit: 5.0.0 pathe: 1.1.1 dev: true - /@vitest/snapshot@1.2.0: - resolution: {integrity: sha512-P33EE7TrVgB3HDLllrjK/GG6WSnmUtWohbwcQqmm7TAk9AVHpdgf7M3F3qRHKm6vhr7x3eGIln7VH052Smo6Kw==} + /@vitest/snapshot@1.3.0: + resolution: {integrity: sha512-swmktcviVVPYx9U4SEQXLV6AEY51Y6bZ14jA2yo6TgMxQ3h+ZYiO0YhAHGJNp0ohCFbPAis1R9kK0cvN6lDPQA==} dependencies: magic-string: 0.30.5 pathe: 1.1.1 pretty-format: 29.7.0 dev: true - /@vitest/spy@1.2.0: - resolution: {integrity: sha512-MNxSAfxUaCeowqyyGwC293yZgk7cECZU9wGb8N1pYQ0yOn/SIr8t0l9XnGRdQZvNV/ZHBYu6GO/W3tj5K3VN1Q==} + /@vitest/spy@1.3.0: + resolution: {integrity: sha512-AkCU0ThZunMvblDpPKgjIi025UxR8V7MZ/g/EwmAGpjIujLVV2X6rGYGmxE2D4FJbAy0/ijdROHMWa2M/6JVMw==} dependencies: - tinyspy: 2.2.0 + tinyspy: 2.2.1 dev: true - /@vitest/utils@1.2.0: - resolution: {integrity: sha512-FyD5bpugsXlwVpTcGLDf3wSPYy8g541fQt14qtzo8mJ4LdEpDKZ9mQy2+qdJm2TZRpjY5JLXihXCgIxiRJgi5g==} + /@vitest/utils@1.3.0: + resolution: {integrity: sha512-/LibEY/fkaXQufi4GDlQZhikQsPO2entBKtfuyIpr1jV4DpaeasqkeHjhdOhU24vSHshcSuEyVlWdzvv2XmYCw==} dependencies: diff-sequences: 29.6.3 estree-walker: 3.0.3 @@ -6195,7 +6195,7 @@ packages: string.prototype.trimstart: 1.0.7 typed-array-buffer: 1.0.1 typed-array-byte-length: 1.0.0 - typed-array-byte-offset: 1.0.0 + typed-array-byte-offset: 1.0.1 typed-array-length: 1.0.4 unbox-primitive: 1.0.2 which-typed-array: 1.1.14 @@ -7956,6 +7956,10 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + /js-tokens@8.0.3: + resolution: {integrity: sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==} + dev: true + /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true @@ -9094,7 +9098,7 @@ packages: dependencies: '@jest/schemas': 29.6.3 ansi-styles: 5.2.0 - react-is: 18.1.0 + react-is: 18.2.0 dev: true /pretty-hrtime@1.0.3: @@ -9333,6 +9337,10 @@ packages: resolution: {integrity: sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==} dev: true + /react-is@18.2.0: + resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + dev: true + /react-refresh@0.14.0: resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} engines: {node: '>=0.10.0'} @@ -10175,10 +10183,10 @@ packages: engines: {node: '>=8'} dev: true - /strip-literal@1.3.0: - resolution: {integrity: sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==} + /strip-literal@2.0.0: + resolution: {integrity: sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==} dependencies: - acorn: 8.11.2 + js-tokens: 8.0.3 dev: true /sucrase@3.34.0: @@ -10372,13 +10380,13 @@ packages: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} dev: true - /tinypool@0.8.1: - resolution: {integrity: sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==} + /tinypool@0.8.2: + resolution: {integrity: sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==} engines: {node: '>=14.0.0'} dev: true - /tinyspy@2.2.0: - resolution: {integrity: sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==} + /tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} dev: true @@ -10586,6 +10594,18 @@ packages: is-typed-array: 1.1.12 dev: true + /typed-array-byte-offset@1.0.1: + resolution: {integrity: sha512-tcqKMrTRXjqvHN9S3553NPCaGL0VPgFI92lXszmrE8DMhiDPLBYLlvo8Uu4WZAAX/aGqp/T1sbA4ph8EWjDF9Q==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.6 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.1 + is-typed-array: 1.1.13 + dev: true + /typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} dependencies: @@ -10834,8 +10854,8 @@ packages: engines: {node: '>= 0.8'} dev: true - /vite-node@1.2.0: - resolution: {integrity: sha512-ETnQTHeAbbOxl7/pyBck9oAPZZZo+kYnFt1uQDD+hPReOc+wCjXw4r4jHriBRuVDB5isHmPXxrfc1yJnfBERqg==} + /vite-node@1.3.0: + resolution: {integrity: sha512-D/oiDVBw75XMnjAXne/4feCkCEwcbr2SU1bjAhCcfI5Bq3VoOHji8/wCPAfUkDIeohJ5nSZ39fNxM3dNZ6OBOA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true dependencies: @@ -10907,15 +10927,15 @@ packages: fsevents: 2.3.3 dev: true - /vitest@1.2.0(jsdom@23.2.0): - resolution: {integrity: sha512-Ixs5m7BjqvLHXcibkzKRQUvD/XLw0E3rvqaCMlrm/0LMsA0309ZqYvTlPzkhh81VlEyVZXFlwWnkhb6/UMtcaQ==} + /vitest@1.3.0(jsdom@23.2.0): + resolution: {integrity: sha512-V9qb276J1jjSx9xb75T2VoYXdO1UKi+qfflY7V7w93jzX7oA/+RtYE6TcifxksxsZvygSSMwu2Uw6di7yqDMwg==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': ^1.0.0 - '@vitest/ui': ^1.0.0 + '@vitest/browser': 1.3.0 + '@vitest/ui': 1.3.0 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -10932,13 +10952,12 @@ packages: jsdom: optional: true dependencies: - '@vitest/expect': 1.2.0 - '@vitest/runner': 1.2.0 - '@vitest/snapshot': 1.2.0 - '@vitest/spy': 1.2.0 - '@vitest/utils': 1.2.0 + '@vitest/expect': 1.3.0 + '@vitest/runner': 1.3.0 + '@vitest/snapshot': 1.3.0 + '@vitest/spy': 1.3.0 + '@vitest/utils': 1.3.0 acorn-walk: 8.3.2 - cac: 6.7.14 chai: 4.4.1 debug: 4.3.4 execa: 8.0.1 @@ -10948,11 +10967,11 @@ packages: pathe: 1.1.1 picocolors: 1.0.0 std-env: 3.7.0 - strip-literal: 1.3.0 + strip-literal: 2.0.0 tinybench: 2.5.1 - tinypool: 0.8.1 + tinypool: 0.8.2 vite: 5.0.11 - vite-node: 1.2.0 + vite-node: 1.3.0 why-is-node-running: 2.2.2 transitivePeerDependencies: - less diff --git a/frontend/src/business/hooks/webRTC/__tests__/useControllMedia.spec.ts b/frontend/src/business/hooks/webRTC/__tests__/useControllMedia.spec.ts deleted file mode 100644 index c347644d..00000000 --- a/frontend/src/business/hooks/webRTC/__tests__/useControllMedia.spec.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { useControllMedia, useMedia } from '..'; -import { renderHook } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; - -import { WebRTC } from '@business/services'; - -import { useMediaInfo } from '@stores/zustandStores'; - -import { __setMockMediaStreamTracks, mockMediaStream } from '@mocks/webRTC'; - -vi.mock('@business/services'); -vi.mock('../useMedia'); - -type HTMLVideoElementRef = React.RefObject; - -function createSpyMediaInfoChannel(mockReturnValue?: Partial>) { - return vi.spyOn(WebRTC.getInstance(), 'getDataChannel').mockReturnValueOnce(mockReturnValue as any); -} - -test('it', () => expect(1).toBe(1)); - -function filterVideoTrackByEnabled(enabled: boolean) { - return (mediaStream: MediaStream) => mediaStream.getVideoTracks().filter(track => track.enabled === enabled); -} - -function rerenderHook({ localVideoRef }: { localVideoRef: any }) { - return renderHook(() => useControllMedia({ localVideoRef })).result.current; -} -describe('useControllMedia 훅', () => { - let webRTC: WebRTC; - beforeEach(() => { - vi.clearAllMocks(); - webRTC = WebRTC.getInstance(); - __setMockMediaStreamTracks([ - { kind: 'video', id: 'video1', enabled: true } as any as MediaStreamTrack, - { kind: 'video', id: 'video2', enabled: false } as any as MediaStreamTrack, - { kind: 'audio', id: 'audio1', enabled: true } as any as MediaStreamTrack, - { kind: 'audio', id: 'audio2', enabled: false } as any as MediaStreamTrack, - ]); - }); - - describe('setLocalVideoSrcObj함수는 ', () => { - [ - { - scenario: 'localVideoRef.current가 없을 때 실행되지 않는다.', - localVideoRef: { current: undefined }, - }, - { - scenario: 'localVideoRef.current가 있을 때 실행되면 srcObject에 stream을 할당한다.', - localVideoRef: { current: { srcObject: undefined } }, - }, - ].forEach(({ scenario, localVideoRef }) => { - it(scenario, () => { - const stream = 'myTestStream' as any as MediaStream; - const { setLocalVideoSrcObj } = rerenderHook({ localVideoRef }); - - setLocalVideoSrcObj(stream); - - if (!localVideoRef.current) { - expect(localVideoRef.current).toBeUndefined(); - } else { - expect(localVideoRef.current?.srcObject).toBe(stream); - } - }); - }); - }); - - describe('toggleVideo함수는 ', () => { - [ - { - scenario: 'localVideoRef.current가 없을 때 실행되지 않는다.', - localVideoRef: { current: undefined }, - }, - { - scenario: '채널이 존재하지 않거나 열리있지 않아도 내 비디오 상태는 토글되고, toggleMyVideo()가 호출된다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - }, - - { - scenario: '있는데 채널이 존재하지 않으면 실행되지 않는다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - }, - { - scenario: '있는데 채널이 열려있지 않으면 실행되지 않는다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - spy: () => ({ - spyMediaInfoChannel: createSpyMediaInfoChannel({ readyState: 'close', send: vi.fn() }), - }), - }, - { - scenario: '있는데 채널이 열려있으면 실행된다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - spy: () => ({ - spyMediaInfoChannel: createSpyMediaInfoChannel({ readyState: 'open', send: vi.fn() }), - }), - }, - { - scenario: '채널이 존재하고 열려있으면 현재 비디오 트랙 0번째를 mediaInfoChannel채널로 전송한다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - spy: () => ({ - spyMediaInfoChannel: createSpyMediaInfoChannel({ readyState: 'open', send: vi.fn() }), - }), - }, - ].forEach(({ scenario, localVideoRef, spy }) => { - it(scenario, () => { - const toggleMyVideo = vi.spyOn(useMediaInfo.getState(), 'toggleMyVideo'); - const beforeVideoEnabled = filterVideoTrackByEnabled(true)(mockMediaStream).length; - const { spyMediaInfoChannel } = spy?.() ?? {}; - const { toggleVideo } = rerenderHook({ localVideoRef }); - - act(() => { - toggleVideo(); - }); - const afterVideoDisabled = filterVideoTrackByEnabled(false)(mockMediaStream).length; - - if (!localVideoRef.current) { - expect(localVideoRef.current).toBeUndefined(); - return; - } - - // 이전에 활성화 됐던 비디오들은 비활성화된다. - expect(beforeVideoEnabled).toEqual(afterVideoDisabled); - expect(toggleMyVideo).toBeCalledTimes(1); - - const mediaInfoChannel = spyMediaInfoChannel?.mock.results[0].value; - if (!mediaInfoChannel || mediaInfoChannel.readyState !== 'open') { - expect(mediaInfoChannel?.readyState).not.toBe('open'); - return; - } - - // 첫번째 비디오 트랙의 enabled를 전송 - const videoTracks = mockMediaStream.getVideoTracks(); - expect(mediaInfoChannel.send).toBeCalledWith( - JSON.stringify([{ type: 'video', onOrOff: videoTracks[0].enabled }]), - ); - - // 두번째 비디오 트랙의 enabled는 전송되지 않음 - expect(mediaInfoChannel.send).not.toBeCalledWith( - JSON.stringify([{ type: 'video', onOrOff: videoTracks[1].enabled }]), - ); - }); - }); - }); - - describe('toggleAudio함수는 ', () => { - [ - { - scenario: 'localVideoRef.current가 없을 때 실행되지 않는다.', - localVideoRef: { current: undefined }, - }, - { - scenario: '채널이 존재하지 않거나 열리있지 않아도 내 비디오 상태는 토글되고, toggleMyMic()가 호출된다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - }, - - { - scenario: '있는데 채널이 존재하지 않으면 실행되지 않는다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - }, - { - scenario: '있는데 채널이 열려있지 않으면 실행되지 않는다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - spy: () => ({ - spyMediaInfoChannel: createSpyMediaInfoChannel({ readyState: 'close' }), - }), - }, - { - scenario: '있는데 채널이 열려있으면 실행된다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - spy: () => ({ - spyMediaInfoChannel: createSpyMediaInfoChannel({ readyState: 'open', send: vi.fn() }), - }), - }, - { - scenario: '채널이 존재하고 열려있으면 현재 비디오 트랙 0번째를 mediaInfoChannel채널로 전송한다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - spy: () => ({ - spyMediaInfoChannel: createSpyMediaInfoChannel({ readyState: 'open', send: vi.fn() }), - }), - }, - ].forEach(({ scenario, localVideoRef, spy }) => { - it(scenario, () => { - const toggleMyMic = vi.spyOn(useMediaInfo.getState(), 'toggleMyMic'); - const beforeAudioEnabled = filterVideoTrackByEnabled(true)(mockMediaStream).length; - const { spyMediaInfoChannel } = spy?.() ?? {}; - const { toggleAudio } = renderHook(() => - useControllMedia({ localVideoRef: localVideoRef as HTMLVideoElementRef }), - ).result.current; - - act(() => { - toggleAudio(); - }); - - const afterAudioDisabled = filterVideoTrackByEnabled(false)(mockMediaStream).length; - - if (!localVideoRef.current) { - expect(localVideoRef.current).toBeUndefined(); - return; - } - - // 이전에 활성화 됐던 오디오들은 비활성화된다. - expect(beforeAudioEnabled).toEqual(afterAudioDisabled); - expect(toggleMyMic).toBeCalledTimes(1); - - const mediaInfoChannel = spyMediaInfoChannel?.mock.results[0].value; - if (!mediaInfoChannel || mediaInfoChannel.readyState !== 'open') { - expect(mediaInfoChannel?.readyState).not.toBe('open'); - return; - } - - // 첫번째 오디오 트랙의 enabled를 전송 - const audioTracks = mockMediaStream.getAudioTracks(); - expect(mediaInfoChannel.send).toBeCalledWith( - JSON.stringify([{ type: 'audio', onOrOff: audioTracks[0].enabled }]), - ); - - // 두번째 오디오 트랙의 enabled는 전송되지 않음 - expect(mediaInfoChannel.send).not.toBeCalledWith( - JSON.stringify([{ type: 'audio', onOrOff: audioTracks[1].enabled }]), - ); - }); - }); - }); - - describe('changeMyVideoTrack 함수는 로컬스트림을 변경하고, 상대방에게 전달할 현재 내 스트림을 바꾼다', () => { - [ - { - scenario: 'id를 입력으로 넣지 않으면 getLocalStream에서 가져온 비디오 트랙으로 위 작업을 수행 ', - localVideoRef: { current: { srcObject: mockMediaStream } }, - }, - { - scenario: '만약 id를 입력으로 넣으면 해당 비디오 트랙 id를 상태로 설정한다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - id: 'testVideoTrackID', - }, - ].forEach(({ scenario, id, localVideoRef }) => { - it(scenario, async () => { - const spySetSelectedCameraID = vi.spyOn(useMediaInfo.getState(), 'setSelectedCameraID'); - const spyGetLocalStream = vi.spyOn(useMedia(), 'getLocalStream'); - const { changeMyVideoTrack } = rerenderHook({ localVideoRef }); - - await act(async () => { - await changeMyVideoTrack(id); - }); - - if (id) { - expect(spyGetLocalStream).toBeCalledWith({ cameraID: id }); - expect(spySetSelectedCameraID).toBeCalledWith(id); - } else { - expect(spyGetLocalStream).toBeCalledWith({}); - } - - // getLocalStream의 결과는 stream - const stream = spyGetLocalStream.mock.results[0].value; - // localVideo가 getLocalStream의 결과로 설정된다. - expect(localVideoRef.current.srcObject).toEqual(stream); - expect(webRTC.setLocalStream).toBeCalledWith(stream); - expect(webRTC.replacePeerconnectionVideoTrack2NowLocalStream).toBeCalledTimes(1); - }); - }); - }); - - describe('changeMyAudioTrack 함수는 로컬스트림을 변경하고, 상대방에게 전달할 현재 내 스트림을 바꾼다', () => { - [ - { - scenario: 'id를 입력으로 넣지 않으면 getLocalStream에서 가져온 오디오 트랙으로 위 작업을 수행 ', - localVideoRef: { current: { srcObject: mockMediaStream } }, - }, - { - scenario: '만약 id를 입력으로 넣으면 해당 오디오 트랙 id를 상태로 설정한다.', - localVideoRef: { current: { srcObject: mockMediaStream } }, - id: 'testAudioTrackID', - }, - ].forEach(({ scenario, id, localVideoRef }) => { - it(scenario, async () => { - const spySetSelectedAudioID = vi.spyOn(useMediaInfo.getState(), 'setSelectedAudioID'); - const spyGetLocalStream = vi.spyOn(useMedia(), 'getLocalStream'); - const { changeMyAudioTrack } = rerenderHook({ localVideoRef }); - - await act(async () => { - await changeMyAudioTrack(id); - }); - - if (id) { - expect(spyGetLocalStream).toBeCalledWith({ audioID: id }); - expect(spySetSelectedAudioID).toBeCalledWith(id); - } else { - expect(spyGetLocalStream).toBeCalledWith({}); - } - - // getLocalStream의 결과는 stream - const stream = spyGetLocalStream.mock.results[0].value; - // localVideo가 getLocalStream의 결과로 설정된다. - expect(localVideoRef.current.srcObject).toEqual(stream); - expect(webRTC.setLocalStream).toBeCalledWith(stream); - expect(webRTC.replacePeerconnectionAudioTrack2NowLocalStream).toBeCalledTimes(1); - }); - }); - }); -}); diff --git a/frontend/src/business/hooks/webRTC/__tests__/useDataChannel.spec.ts b/frontend/src/business/hooks/webRTC/__tests__/useDataChannel.spec.ts index ce2cb51a..4953f2af 100644 --- a/frontend/src/business/hooks/webRTC/__tests__/useDataChannel.spec.ts +++ b/frontend/src/business/hooks/webRTC/__tests__/useDataChannel.spec.ts @@ -8,27 +8,28 @@ vi.mock('@business/services'); describe('useDataChannel 테스트', () => { let mockWebRTCModule = WebRTC.getInstance(); - function rerenderHook() { - const { - rerender, - result: { - current: { dataChannels, initDataChannels }, - }, - } = renderHook(() => useDataChannel()); - return { rerender, dataChannels, initDataChannels }; - } - const addEventListener = vi.fn(); beforeAll(() => { const mediaInfoChannelStub: any = { addEventListener }; vi.spyOn(mockWebRTCModule, 'addDataChannel').mockReturnValue(mediaInfoChannelStub); + + window.MediaStream = {} as any; }); afterEach(() => { vi.clearAllMocks(); }); + function rerenderHook() { + const { + rerender, + result: { + current: { dataChannels, initDataChannels }, + }, + } = renderHook(() => useDataChannel()); + return { rerender, dataChannels, initDataChannels }; + } describe('initDataChannels 함수 테스트: 아래 A ~ D의 함수가 실행됨', () => { describe('A. initMediaInfoChannel 함수 테스트', () => { it('mediaInfoChannel 데이터 채널 추가 + message와 open 이벤트가 등록됨.', () => { diff --git a/frontend/src/business/hooks/webRTC/__tests__/useDataChannelEventListener.spec.ts b/frontend/src/business/hooks/webRTC/__tests__/useDataChannelEventListener.spec.ts index d2377ff2..3185487d 100644 --- a/frontend/src/business/hooks/webRTC/__tests__/useDataChannelEventListener.spec.ts +++ b/frontend/src/business/hooks/webRTC/__tests__/useDataChannelEventListener.spec.ts @@ -1,15 +1,11 @@ import { useDataChannelEventListener } from '../useDataChannelEventListener'; import { act, renderHook } from '@testing-library/react'; -import { WebRTC } from '@business/services'; - import { useMediaInfo, useProfileInfo } from '@stores/zustandStores'; vi.mock('@business/services'); describe('useDataChannelEventListener 훅 테스트', () => { - let mockWebRTCModule = WebRTC.getInstance(); - function rerenderHook() { const { result: { @@ -40,6 +36,11 @@ describe('useDataChannelEventListener 훅 테스트', () => { vi.clearAllMocks(); }); + test('test', () => { + // const a = new MediaStream(); + // console.log(a); + expect(1).toBe(1); + }); describe(`setMediaStates 함수 테스트`, () => { [ { @@ -128,20 +129,19 @@ describe('useDataChannelEventListener 훅 테스트', () => { }, ].forEach(({ scenario, audioTrack, videoTrack }) => { it(scenario, () => { - vi.spyOn(mockWebRTCModule, 'getFirstAudioTrack').mockReturnValueOnce(audioTrack as any); - vi.spyOn(mockWebRTCModule, 'getFirstVideoTrack').mockReturnValueOnce(videoTrack as any); + vi.spyOn(useMediaInfo.getState(), 'myVideoOn', 'get').mockReturnValueOnce(videoTrack.enabled); + vi.spyOn(useMediaInfo.getState(), 'myMicOn', 'get').mockReturnValueOnce(audioTrack.enabled); + const RTCDataChannelSendFn = vi.fn(); const { sendNowMediaStates } = rerenderHook(); - act(() => { - sendNowMediaStates.call({ send: RTCDataChannelSendFn } as any); - }); + sendNowMediaStates.call({ send: RTCDataChannelSendFn } as any); expect(RTCDataChannelSendFn).toBeCalledWith( JSON.stringify([ - { type: 'audio', onOrOff: audioTrack?.enabled }, - { type: 'video', onOrOff: videoTrack?.enabled }, + { type: 'video', onOrOff: videoTrack.enabled }, + { type: 'audio', onOrOff: audioTrack.enabled }, ]), ); }); diff --git a/frontend/src/business/hooks/webRTC/__tests__/useMedia.spec.ts b/frontend/src/business/hooks/webRTC/__tests__/useMedia.spec.ts index 6d259fac..76a9cb9b 100644 --- a/frontend/src/business/hooks/webRTC/__tests__/useMedia.spec.ts +++ b/frontend/src/business/hooks/webRTC/__tests__/useMedia.spec.ts @@ -21,15 +21,17 @@ const mockAudioIdState = 'audioIDState'; describe('useMedia훅', () => { let getUserMediaStream = vi.spyOn(Media, 'getUserMediaStream').mockResolvedValue(mockMediaStream); - let getLocalStream: any; function rerenderHook() { - const util = renderHook(() => useMedia()); - getLocalStream = util.result.current.getLocalStream; + const { + result: { + current: { getAudioStream, getVideoStream }, + }, + } = renderHook(() => useMedia()); + return { getAudioStream, getVideoStream }; } beforeEach(() => { - rerenderHook(); __setMockMediaStreamTracks([ createFakeMediaStreamTrack('video', mockCameraId), createFakeMediaStreamTrack('audio', mockAudioId), @@ -40,101 +42,72 @@ describe('useMedia훅', () => { vi.clearAllMocks(); }); - describe('getLocalStream 함수', () => { - describe('audioId 테스트', () => { - it('함수의 인수로 전달된 audioId ✅: 해당 Id로 MediaStream을 받아옴', async () => { - await getLocalStream({ audioID: mockAudioId }); + describe('getAudioStream 테스트', () => { + it('함수의 인수로 전달된 audioId ✅: 해당 Id로 MediaStream을 받아옴', async () => { + const { getAudioStream } = rerenderHook(); - expect(getUserMediaStream).toBeCalledWith({ - audio: { deviceId: mockAudioId }, - video: defaultVideoOptions, - }); - }); + await getAudioStream({ audioID: mockAudioId }); - it('함수의 인수로 전달된 audioId ❌, 전역의 audioId ✅: 전역의 Id로 MediaStream을 받아옴', async () => { - act(() => { - useMediaInfo.getState().setSelectedAudioID(mockAudioIdState); - }); - rerenderHook(); - await getLocalStream(); - - expect(getUserMediaStream).toBeCalledWith({ - audio: { deviceId: mockAudioIdState }, - video: defaultVideoOptions, - }); + expect(getUserMediaStream).toBeCalledWith({ + audio: { deviceId: mockAudioId }, }); + }); - it('함수의 인수로 전달된 audioId ❌, 전역의 audioId ❌: 기본 옵션이 들어감', async () => { - await getLocalStream({ cameraID: 'mockCameraId' }); - - expect(getUserMediaStream).toBeCalledWith({ - audio: defaultAudioOptions, - video: createDefaultVideoOptions('mockCameraId'), - }); + it('함수의 인수로 전달된 audioId ❌, 전역의 audioId ✅: 전역의 Id로 MediaStream을 받아옴', async () => { + act(() => { + useMediaInfo.getState().setSelectedAudioID(mockAudioIdState); }); - }); + const { getAudioStream } = rerenderHook(); - describe('cameraId 테스트', () => { - it('함수의 인수로 전달된 videoId ✅: 해당 Id로 MediaStream을 받아옴', async () => { - await getLocalStream({ cameraID: mockCameraId }); + await getAudioStream(); - expect(getUserMediaStream).toBeCalledWith({ - audio: defaultAudioOptions, - video: createDefaultVideoOptions(mockCameraId), - }); + expect(getUserMediaStream).toBeCalledWith({ + audio: { deviceId: mockAudioIdState }, }); + }); - it('함수의 인수로 전달된 videoId ❌, 전역의 videoId ✅: 전역의 Id로 MediaStream을 받아옴', async () => { - act(() => { - useMediaInfo.getState().setSelectedCameraID(mockCameraIdState); - }); - rerenderHook(); + it('함수의 인수로 전달된 audioId ❌, 전역의 audioId ❌: 기본 옵션이 들어감', async () => { + const { getAudioStream } = rerenderHook(); - await getLocalStream(); + await getAudioStream(); - expect(getUserMediaStream).toBeCalledWith({ - audio: defaultAudioOptions, - video: createDefaultVideoOptions(mockCameraIdState), - }); + expect(getUserMediaStream).toBeCalledWith({ + audio: defaultAudioOptions, }); + }); + }); - it('함수의 인수로 전달된 videoId ❌, 전역의 videoId ❌: 기본 옵션이 들어감', async () => { - await getLocalStream(); + describe('getVideoStream 테스트', () => { + it('함수의 인수로 전달된 videoId ✅: 해당 Id로 MediaStream을 받아옴', async () => { + const { getVideoStream } = rerenderHook(); - expect(getUserMediaStream).toBeCalledWith({ - audio: defaultAudioOptions, - video: defaultVideoOptions, - }); + await getVideoStream({ cameraID: mockCameraId }); + + expect(getUserMediaStream).toBeCalledWith({ + video: createDefaultVideoOptions(mockCameraId), }); }); - describe('내 마이크가 꺼져있다면(전역상태 myMicOn이 false라면)', () => { - it('stream의 audioTrack들의 enabled가 false가 됨', async () => { - act(() => { - useMediaInfo.getState().setMyMicOn(false); - }); - rerenderHook(); + it('함수의 인수로 전달된 videoId ❌, 전역의 videoId ✅: 전역의 Id로 MediaStream을 받아옴', async () => { + act(() => { + useMediaInfo.getState().setSelectedCameraID(mockCameraIdState); + }); + const { getVideoStream } = rerenderHook(); - const media = await getLocalStream(); + await getVideoStream(); - media.getAudioTracks().forEach((track: any) => { - expect(track.enabled).toBe(false); - }); + expect(getUserMediaStream).toBeCalledWith({ + video: createDefaultVideoOptions(mockCameraIdState), }); }); - describe('내 카메라가 꺼져있다면(전역상태 myVideoOn이 false라면)', () => { - it('stream의 videoTrack들의 enabled가 false가 됨', async () => { - act(() => { - useMediaInfo.getState().setMyVideoOn(false); - }); - rerenderHook(); + it('함수의 인수로 전달된 videoId ❌, 전역의 videoId ❌: 기본 옵션이 들어감', async () => { + const { getVideoStream } = rerenderHook(); - const media = await getLocalStream(); + await getVideoStream(); - media.getVideoTracks().forEach((track: any) => { - expect(track.enabled).toBe(false); - }); + expect(getUserMediaStream).toBeCalledWith({ + video: defaultVideoOptions, }); }); }); diff --git a/frontend/src/business/hooks/webRTC/__tests__/useMediaStream.spec.ts b/frontend/src/business/hooks/webRTC/__tests__/useMediaStream.spec.ts new file mode 100644 index 00000000..2c4150eb --- /dev/null +++ b/frontend/src/business/hooks/webRTC/__tests__/useMediaStream.spec.ts @@ -0,0 +1,255 @@ +import { useMediaStream } from '..'; +import { act, renderHook } from '@testing-library/react'; + +import { WebRTC } from '@business/services'; + +import { useMediaInfo, useMediaStreamStore } from '@stores/zustandStores'; + +import { __setMockNavigatorWithTracks, createFakeMediaStreamTrack } from '@mocks/webRTC'; + +vi.mock('@business/services'); +vi.mock('.'); +describe('useMediaStream 훅', () => { + const webRTC = WebRTC.getInstance(); + const rerenderHook = () => { + const { + result: { + current: { + replacePeerconnectionTrack, + changeMediaTrack, + disconnectMediaStream, + localStream, + remoteStream, + toggleMediaOnOff, + }, + }, + } = renderHook(() => useMediaStream()); + return { + changeMediaTrack, + disconnectMediaStream, + localStream, + remoteStream, + replacePeerconnectionTrack, + toggleMediaOnOff, + }; + }; + beforeAll(() => { + __setMockNavigatorWithTracks([ + createFakeMediaStreamTrack('audio', 'audio1'), + createFakeMediaStreamTrack('video', 'video1'), + ]); + }); + afterAll(() => { + vi.clearAllMocks(); + }); + describe('toggleMediaOnOff 함수 테스트', () => { + [ + { + scenario: 'mediaInfoChannel이 open이면 반대 상태를 상대에게 전달한다.', + readyState: 'open', + type: 'audio', + enabled: { + myVideo: false, + myMic: true, + }, + expected: JSON.stringify([{ type: 'audio', onOrOff: false }]), + }, + { + scenario: 'mediaInfoChannel이 closed면 상태를 상대에게 전달하지 않는다.', + readyState: 'closed', + type: 'video', + enabled: { + myVideo: true, + myMic: false, + }, + }, + { + scenario: 'mediaEnabled가 true일 때, 해당하는 type의 track을 멈춘다.(video)', + readyState: 'open', + type: 'video', + enabled: { + myVideo: true, + myMic: false, + }, + expected: JSON.stringify([{ type: 'video', onOrOff: false }]), + }, + { + scenario: 'mediaEnabled가 false일 때, 해당하는 type의 track을 추가한다.(video)', + readyState: 'open', + type: 'video', + enabled: { + myVideo: false, + myMic: false, + }, + expected: JSON.stringify([{ type: 'video', onOrOff: true }]), + }, + ].forEach(({ scenario, readyState, type, enabled, expected }) => { + it(scenario, async () => { + /** + * 준비 + */ + const send = vi.fn(); + vi.spyOn(webRTC, 'getDataChannel').mockReturnValueOnce({ readyState, send } as any); + + const getTracks = vi.fn().mockReturnValue([createFakeMediaStreamTrack(type as any, `${type}1`)]); + const addTrack = vi.fn(); + const removeTrack = vi.fn(); + vi.spyOn(useMediaStreamStore.getState(), 'localStream', 'get').mockReturnValue({ + getTracks, + addTrack, + removeTrack, + } as any); + + vi.spyOn(useMediaInfo.getState(), 'myMicOn', 'get').mockReturnValue(enabled.myMic); + vi.spyOn(useMediaInfo.getState(), 'myVideoOn', 'get').mockReturnValue(enabled.myVideo); + + /** + * 실행 + */ + const { toggleMediaOnOff } = rerenderHook(); + await act(async () => { + await toggleMediaOnOff({ type: type as any }); + }); + + /** + * 검증 + */ + // readyState가 open이면 상대에게 전달한다. + if (expected) { + expect(send).toBeCalledWith(expected); + } + // readyState가 closed면 상대에게 전달하지 않는다. + else { + expect(send).not.toBeCalled(); + } + + // mediaEnabled가 true일 때, 해당하는 type의 track을 멈춘다. + if (enabled[type === 'video' ? 'myVideo' : 'myMic']) { + expect(getTracks).toBeCalled(); + expect(removeTrack).toBeCalled(); + } + // mediaEnabled가 false일 때, 해당하는 type의 track을 추가한다. + else { + expect(addTrack).toBeCalled(); + } + }); + }); + }); + describe('changeMediaTrack 함수 테스트', () => { + // it('id가 존재할 때, 해당하는 type의 id를 변경한다.', () => {}); + // it('id가 존재할 때, 해당하는 type의 id를 변경하지 않는다.', () => {}); + // it('id와 관계없이 해당하는 type의 track을 멈추고 새로운 track을 추가한다.', () => {}); + [ + { + scenario: 'id가 존재할 때, 해당하는 type 상태의 id를 변경한다.', + type: 'audio', + id: 'audio2', + }, + { + scenario: 'id가 존재하지 않을 때, 해당하는 type 상태의 id를 변경하지 않는다.', + type: 'video', + id: undefined, + }, + { + scenario: 'id와 관계없이 해당하는 type의 track을 멈추고 새로운 track을 추가한다.', + type: 'video', + id: 'video2', + }, + ].forEach(({ scenario, type, id }) => { + it(scenario, async () => { + /** + * 준비 + */ + const getTracks = vi.fn().mockReturnValue([createFakeMediaStreamTrack(type as any, `${type}1`)]); + const addTrack = vi.fn(); + const removeTrack = vi.fn(); + vi.spyOn(useMediaStreamStore.getState(), 'localStream', 'get').mockReturnValue({ + getTracks, + addTrack, + removeTrack, + getVideoTracks: vi.fn().mockReturnValue([createFakeMediaStreamTrack('video', 'video1')]), + getAudioTracks: vi.fn().mockReturnValue([createFakeMediaStreamTrack('audio', 'audio1')]), + } as any); + + const setSelectedCameraID = vi.spyOn(useMediaInfo.getState(), 'setSelectedCameraID'); + const setSelectedAudioID = vi.spyOn(useMediaInfo.getState(), 'setSelectedAudioID'); + + /** + * 실행 + */ + const { changeMediaTrack } = rerenderHook(); + await act(async () => { + await changeMediaTrack({ type: type as any, id }); + }); + + /** + * 검증 + */ + // id가 존재할 때, 해당하는 type 상태의 id를 변경한다. + if (id) { + expect(type === 'audio' ? setSelectedAudioID : setSelectedCameraID).toBeCalledWith(id); + } + // id가 존재하지 않을 때, 해당하는 type 상태의 id를 변경하지 않는다. + else { + expect(setSelectedCameraID).not.toBeCalled(); + expect(setSelectedAudioID).not.toBeCalled(); + } + + // id와 관계없이 해당하는 type의 track을 멈추고 새로운 track을 추가한다. + expect(getTracks).toBeCalled(); + expect(removeTrack).toBeCalled(); + expect(addTrack).toBeCalled(); + }); + }); + }); + + describe('disconnectMediaStream 함수 테스트', () => { + it('localStream의 track을 모두 멈추고 제거한다.', () => { + const stop = vi.fn(); + const removeTrack = vi.fn(); + vi.spyOn(useMediaStreamStore.getState(), 'localStream', 'get').mockReturnValue({ + getTracks: vi.fn().mockReturnValue([{ stop } as any, { stop } as any]), + removeTrack, + } as any); + + const { disconnectMediaStream } = rerenderHook(); + disconnectMediaStream(); + + expect(stop).toBeCalledTimes(2); + expect(removeTrack).toBeCalledTimes(2); + }); + }); + + describe('replacePeerconnectionTrack 함수 테스트', () => { + it('해당하는 type의 track을 변경한다.', () => { + const getVideoTracks = vi.fn().mockReturnValue([createFakeMediaStreamTrack('video', 'video1')]); + const getAudioTracks = vi.fn().mockReturnValue([createFakeMediaStreamTrack('audio', 'audio1')]); + vi.spyOn(useMediaStreamStore.getState(), 'localStream', 'get').mockReturnValue({ + getVideoTracks, + getAudioTracks, + } as any); + const replacePeerconnectionSendersTrack = vi.fn(); + vi.spyOn(webRTC, 'replacePeerconnectionSendersTrack').mockImplementation(replacePeerconnectionSendersTrack); + + const { replacePeerconnectionTrack } = rerenderHook(); + replacePeerconnectionTrack('video'); + + expect(getVideoTracks).toBeCalled(); + expect(replacePeerconnectionSendersTrack).toBeCalledWith('video', expect.any(Object)); + }); + }); + + describe('localStream, remoteStream 변수 테스트', () => { + it('localStream, remoteStream 변수를 반환한다.', () => { + const localStream = {}; + const remoteStream = {}; + vi.spyOn(useMediaStreamStore.getState(), 'localStream', 'get').mockReturnValue(localStream as any); + vi.spyOn(useMediaStreamStore.getState(), 'remoteStream', 'get').mockReturnValue(remoteStream as any); + + const { localStream: resultLocalStream, remoteStream: resultRemoteStream } = rerenderHook(); + + expect(resultLocalStream).toBe(localStream); + expect(resultRemoteStream).toBe(remoteStream); + }); + }); +}); diff --git a/frontend/src/business/hooks/webRTC/__tests__/useStreamVideoRef.spec.ts b/frontend/src/business/hooks/webRTC/__tests__/useStreamVideoRef.spec.ts deleted file mode 100644 index 219135ee..00000000 --- a/frontend/src/business/hooks/webRTC/__tests__/useStreamVideoRef.spec.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { useStreamVideoRef } from '..'; -import { act, renderHook } from '@testing-library/react'; -import React from 'react'; - -import { WebRTC } from '@business/services'; - -import { mockMediaStream } from '@mocks/webRTC'; - -vi.mock('@business/services'); - -const renderUserStreamVideoRef = () => { - const { - result: { - current: { localVideoRef, remoteVideoRef }, - }, - rerender, - } = renderHook(() => useStreamVideoRef()); - - return { localVideoRef, remoteVideoRef, rerender }; -}; - -const initRemoteVideoRef = (remoteVideoRef: React.MutableRefObject) => { - act(() => { - (remoteVideoRef.current as any) = document.createElement('video'); - }); -}; - -const setRefToStrem = (ref: React.MutableRefObject, stream: MediaStream) => { - act(() => { - (ref.current as any).srcObject = stream; - }); -}; - -const createMockMediaStream = (id: string) => ({ ...mockMediaStream, id }); - -describe('useStreamVideoRef 훅', () => { - let webRTC: WebRTC; - - beforeEach(() => { - vi.clearAllMocks(); - webRTC = WebRTC.getInstance(); - }); - - describe('현재 remoteStream의 id가 변경되었을 때', () => { - [ - { - scenario: 'remoteVideoRef가 초기화 되지 않았으면 아무것도 하지안흠', - mediaStream: mockMediaStream, - willInitRemoteVideoRef: false, - resultId: undefined, - }, - { - scenario: 'remoteStream이 없으면 아무것도 하지안흠', - mediaStream: undefined, - willInitRemoteVideoRef: true, - resultId: undefined, - }, - - { - scenario: 'remoteStream의 id가 변경되지 않았으면 useEffect를 실행하지 않는다', - mediaStream: createMockMediaStream('sameId'), - willInitRemoteVideoRef: true, - resultMediaStream: createMockMediaStream('sameId'), - resultId: 'sameId', - }, - { - scenario: 'remoteStream의 id가 변경되면 remoteVideoRef.srcObject를 새로운 remoteStream으로 변경한다', - mediaStream: createMockMediaStream('oldId'), - willInitRemoteVideoRef: true, - resultMediaStream: createMockMediaStream('newId'), - resultId: 'newId', - runBeforeRerender: [ - () => { - vi.spyOn(webRTC, 'getRemoteStream').mockReturnValue(createMockMediaStream('newId')); - }, - ], - }, - ].forEach(({ scenario, mediaStream, willInitRemoteVideoRef, runBeforeRerender, resultId }) => { - it(scenario, () => { - vi.spyOn(webRTC, 'getRemoteStream').mockReturnValue(mediaStream); - - const { remoteVideoRef, rerender } = renderUserStreamVideoRef(); - - willInitRemoteVideoRef && initRemoteVideoRef(remoteVideoRef); - runBeforeRerender?.[0]?.(); - rerender(); - - expect((remoteVideoRef.current as any)?.srcObject?.id).toBe(resultId); - }); - }); - }); - - describe('remoteVideoRef가 변경되었을 때 (화면이 변경되어 새로운 remoteVideoRef가 들어올 때)', () => { - [ - { - scenario: 'remoteVideoRef가 초기화 되지 않았으면 아무것도 하지안흠', - mediaStreams: [mockMediaStream], - willInitLocalVideoRef: false, - resultId: undefined, - }, - { - scenario: 'remoteStream이 초기화 되지 않았으면 아무것도 하지안흠', - mediaStreams: [undefined], - willInitLocalVideoRef: true, - resultId: undefined, - }, - { - scenario: 'remoteStream의 id가 변경되지 않았으면 useEffect를 실행하지 않는다', - mediaStreams: [createMockMediaStream('sameId')], - willInitLocalVideoRef: true, - runsBeforeRerender: [setRefToStrem], - resultId: 'sameId', - }, - { - scenario: 'remoteStream의 id가 변경되면 remoteVideoRef.srcObject를 새로운 remoteStream으로 변경한다', - mediaStreams: [createMockMediaStream('oldId'), createMockMediaStream('newId')], - willInitLocalVideoRef: true, - runsBeforeRerender: [setRefToStrem, setRefToStrem], - resultId: 'newId', - }, - ].forEach(({ scenario, mediaStreams, willInitLocalVideoRef, runsBeforeRerender, resultId }) => { - it(scenario, () => { - const { localVideoRef, rerender } = renderUserStreamVideoRef(); - - willInitLocalVideoRef && initRemoteVideoRef(localVideoRef); - runsBeforeRerender?.[0]?.(localVideoRef, mediaStreams?.[0]) && rerender(); - runsBeforeRerender?.[1]?.(localVideoRef, mediaStreams?.[1]) && rerender(); - - expect((localVideoRef.current as any)?.srcObject?.id).toBe(resultId); - }); - }); - }); -}); diff --git a/frontend/src/business/hooks/webRTC/__tests__/useWebRTC.spec.ts b/frontend/src/business/hooks/webRTC/__tests__/useWebRTC.spec.ts index 5579a0dc..bcaebfef 100644 --- a/frontend/src/business/hooks/webRTC/__tests__/useWebRTC.spec.ts +++ b/frontend/src/business/hooks/webRTC/__tests__/useWebRTC.spec.ts @@ -1,4 +1,4 @@ -import { resetWebRTCDataChannel, useDataChannel, useMedia, useWebRTC } from '..'; +import { useDataChannel, useWebRTC } from '..'; import { renderHook } from '@testing-library/react'; import { WebRTC, initSignalingSocket } from '@business/services'; @@ -12,10 +12,10 @@ const roomName = 'asklje41'; function rerenderHook() { const { result: { - current: { startWebRTC, endWebRTC }, + current: { startWebRTC, endWebRTC, resetWebRTCDataChannel }, }, } = renderHook(() => useWebRTC()); - return { startWebRTC, endWebRTC }; + return { startWebRTC, endWebRTC, resetWebRTCDataChannel }; } describe('useWebRTC훅', () => { @@ -46,33 +46,31 @@ describe('useWebRTC훅', () => { }); }); - it('peerConnection이 연결되어 있지 않으면 peerConnection을 연결하고, dataChannel을 초기화하고, track을 추가한다.', async () => { + it('peerConnection이 연결되어 있지 않으면 peerConnection을 연결하고, dataChannel을 초기화 한다.', async () => { vi.spyOn(webRTC, 'isConnectedPeerConnection').mockReturnValue(false); - const spyGetLocalStream = vi.spyOn(useMedia(), 'getLocalStream'); const { startWebRTC } = rerenderHook(); await startWebRTC({ roomName }); - const stream = spyGetLocalStream.mock.results[0].value; - expect(webRTC.setLocalStream).toHaveBeenCalledWith(stream); - expect(webRTC.connectRTCPeerConnection).toHaveBeenCalledWith(roomName); expect(initSignalingSocket).toHaveBeenCalledWith({ roomName, onExitUser: expect.any(Function), }); + expect(webRTC.connectRTCPeerConnection).toHaveBeenCalledWith({ roomName, onTrack: expect.any(Function) }); expect(useDataChannel().initDataChannels).toHaveBeenCalled(); - expect(webRTC.addTracks).toHaveBeenCalled(); }); it('유저가 나갔을 때, resetWebRTCDataChannel을 호출하며 데이터채널을 재설정한다.', async () => { - const initDataChannels = vi.fn(); - resetWebRTCDataChannel(webRTC, initDataChannels, roomName); + const initDataChannels = vi.spyOn(useDataChannel(), 'initDataChannels'); + const { resetWebRTCDataChannel } = rerenderHook(); + + resetWebRTCDataChannel(roomName); expect(webRTC.closeRTCPeerConnection).toHaveBeenCalled(); expect(webRTC.closeDataChannels).toHaveBeenCalled(); - expect(webRTC.connectRTCPeerConnection).toHaveBeenCalledWith(roomName); + expect(webRTC.connectRTCPeerConnection).toHaveBeenCalledWith({ roomName, onTrack: expect.any(Function) }); expect(initDataChannels).toHaveBeenCalled(); - expect(webRTC.addTracks).toHaveBeenCalled(); + expect(webRTC.addTrack2PeerConnection).toHaveBeenCalledWith(expect.any(Object), expect.any(Object)); }); }); }); diff --git a/frontend/src/business/hooks/webRTC/index.ts b/frontend/src/business/hooks/webRTC/index.ts index 9af01a7a..ad646b95 100644 --- a/frontend/src/business/hooks/webRTC/index.ts +++ b/frontend/src/business/hooks/webRTC/index.ts @@ -1,7 +1,6 @@ -export * from './useControllMedia'; export * from './useDataChannel'; export * from './useDataChannelEventListener'; export * from './useMedia'; export * from './useSignalingSocket'; -export * from './useStreamVideoRef'; export * from './useWebRTC'; +export * from './useMediaStream'; diff --git a/frontend/src/business/hooks/webRTC/useControllMedia.ts b/frontend/src/business/hooks/webRTC/useControllMedia.ts deleted file mode 100644 index 7a1d3ce3..00000000 --- a/frontend/src/business/hooks/webRTC/useControllMedia.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { WebRTC } from '@business/services'; - -import { useMediaInfo } from '@stores/zustandStores'; - -import { useMedia } from './useMedia'; - -interface useContorollMediaParams { - localVideoRef: React.RefObject; -} - -const toggleTrack = (track: MediaStreamTrack) => { - track.enabled = !track.enabled; -}; - -export function useControllMedia({ localVideoRef }: useContorollMediaParams) { - const { - toggleMyMic: toggleMyMicState, - toggleMyVideo: toggleMyVideoState, - setSelectedAudioID, - setSelectedCameraID, - } = useMediaInfo(state => ({ - toggleMyVideo: state.toggleMyVideo, - toggleMyMic: state.toggleMyMic, - setSelectedAudioID: state.setSelectedAudioID, - setSelectedCameraID: state.setSelectedCameraID, - selectedAudioID: state.selectedAudioID, - selectedCameraID: state.selectedCameraID, - })); - - const { getLocalStream } = useMedia(); - - const webRTC = WebRTC.getInstance(); - - const setLocalVideoSrcObj = (stream: MediaStream) => { - if (!localVideoRef.current) { - return; - } - localVideoRef.current.srcObject = stream; - }; - - const toggleVideo = () => { - if (!localVideoRef.current) { - return; - } - - const videoTrack = localVideoRef.current.srcObject as MediaStream; - videoTrack.getVideoTracks().forEach(toggleTrack); - toggleMyVideoState(); - - const mediaInfoChannel = webRTC.getDataChannel('mediaInfoChannel'); - if (!mediaInfoChannel || mediaInfoChannel.readyState !== 'open') { - return; - } - - const videoTrackenabled = videoTrack.getVideoTracks()[0].enabled; - mediaInfoChannel.send(JSON.stringify([{ type: 'video', onOrOff: videoTrackenabled }])); - }; - - const toggleAudio = () => { - if (!localVideoRef.current) { - return; - } - - const audioTrack = localVideoRef.current.srcObject as MediaStream; - audioTrack.getAudioTracks().forEach(toggleTrack); - toggleMyMicState(); - - const mediaInfoChannel = webRTC.getDataChannel('mediaInfoChannel'); - if (!mediaInfoChannel || mediaInfoChannel.readyState !== 'open') { - return; - } - - const audioTrackenabled = audioTrack.getAudioTracks()[0].enabled; - mediaInfoChannel.send(JSON.stringify([{ type: 'audio', onOrOff: audioTrackenabled }])); - }; - - const changeMyVideoTrack = async (id?: string) => { - const stream = await getLocalStream({ cameraID: id }); - - if (id) { - setSelectedCameraID(id); - } - setLocalVideoSrcObj(stream); - webRTC.setLocalStream(stream); - webRTC.replacePeerconnectionVideoTrack2NowLocalStream(); - }; - - const changeMyAudioTrack = async (id?: string) => { - const stream = await getLocalStream({ audioID: id }); - - if (id) { - setSelectedAudioID(id); - } - setLocalVideoSrcObj(stream); - webRTC.setLocalStream(stream); - webRTC.replacePeerconnectionAudioTrack2NowLocalStream(); - }; - - return { changeMyVideoTrack, changeMyAudioTrack, toggleVideo, toggleAudio, setLocalVideoSrcObj }; -} diff --git a/frontend/src/business/hooks/webRTC/useDataChannelEventListener.ts b/frontend/src/business/hooks/webRTC/useDataChannelEventListener.ts index 3467c12d..11b2be29 100644 --- a/frontend/src/business/hooks/webRTC/useDataChannelEventListener.ts +++ b/frontend/src/business/hooks/webRTC/useDataChannelEventListener.ts @@ -1,16 +1,10 @@ -import { WebRTC } from '@business/services'; -import { HumanSocketManager } from '@business/services/SocketManager'; - -import { useMediaInfo } from '@stores/zustandStores'; -import { useProfileInfo } from '@stores/zustandStores'; +import { useMediaInfo, useProfileInfo } from '@stores/zustandStores/index'; import { array2ArrayBuffer } from '@utils/array'; import { DEFAULT_NICKNAME } from '@constants/nickname'; export function useDataChannelEventListener() { - const webRTC = WebRTC.getInstance(HumanSocketManager.getInstance()); - const { setRemoteMicOn, setRemoteVideoOn } = useMediaInfo(state => ({ setRemoteMicOn: state.setRemoteMicOn, setRemoteVideoOn: state.setRemoteVideoOn, @@ -47,13 +41,11 @@ export function useDataChannelEventListener() { } function sendNowMediaStates(this: RTCDataChannel) { - const audioTrack = webRTC.getFirstAudioTrack(); - const videoTrack = webRTC.getFirstVideoTrack(); - + const { myMicOn, myVideoOn } = useMediaInfo.getState(); this.send( JSON.stringify([ - { type: 'audio', onOrOff: audioTrack?.enabled }, - { type: 'video', onOrOff: videoTrack?.enabled }, + { type: 'video', onOrOff: myVideoOn }, + { type: 'audio', onOrOff: myMicOn }, ]), ); } diff --git a/frontend/src/business/hooks/webRTC/useMedia.ts b/frontend/src/business/hooks/webRTC/useMedia.ts index a2caf04a..fdba5944 100644 --- a/frontend/src/business/hooks/webRTC/useMedia.ts +++ b/frontend/src/business/hooks/webRTC/useMedia.ts @@ -3,41 +3,43 @@ import { getUserMediaStream } from '@business/services/Media'; import { useMediaInfo } from '@stores/zustandStores'; export function useMedia() { - const { myMicOn, myVideoOn, selectedAudioID, selectedCameraID } = useMediaInfo(state => ({ + const { selectedAudioID, selectedCameraID } = useMediaInfo(state => ({ selectedAudioID: state.selectedAudioID, selectedCameraID: state.selectedCameraID, - myMicOn: state.myMicOn, - myVideoOn: state.myVideoOn, })); - const getLocalStream = async ({ audioID, cameraID }: { cameraID?: string; audioID?: string } = {}) => { + const getVideoStream = async ({ cameraID }: { cameraID?: string } = {}) => { + const nowSelectedCameraID = cameraID ?? selectedCameraID; + + const videoOptions = { + withCameraID: { deviceId: nowSelectedCameraID, width: 320, height: 320 }, + default: { facingMode: 'user', width: 320, height: 320 }, + }; + + const video = nowSelectedCameraID ? videoOptions.withCameraID : videoOptions.default; + + const stream = await getUserMediaStream({ video }); + + return stream; + }; + + const getAudioStream = async ({ audioID }: { audioID?: string } = {}) => { const nowSelectedAudioID = audioID || selectedAudioID; - const nowSelectedCameraID = cameraID || selectedCameraID; const audioOptions = { withAudioID: { deviceId: nowSelectedAudioID }, default: true, }; - const videoOptions = { - withCameraID: { deviceId: nowSelectedCameraID, width: 320, height: 320 }, - default: { facingMode: 'user', width: 320, height: 320 }, - }; const audio = nowSelectedAudioID ? audioOptions.withAudioID : audioOptions.default; - const video = nowSelectedCameraID ? videoOptions.withCameraID : videoOptions.default; - const stream = await getUserMediaStream({ audio, video }); + const stream = await getUserMediaStream({ audio }); - if (!myVideoOn) { - stream.getVideoTracks().forEach(track => (track.enabled = false)); - } - if (!myMicOn) { - stream.getAudioTracks().forEach(track => (track.enabled = false)); - } return stream; }; return { - getLocalStream, + getVideoStream, + getAudioStream, }; } diff --git a/frontend/src/business/hooks/webRTC/useMediaStream.ts b/frontend/src/business/hooks/webRTC/useMediaStream.ts new file mode 100644 index 00000000..c38e35d2 --- /dev/null +++ b/frontend/src/business/hooks/webRTC/useMediaStream.ts @@ -0,0 +1,111 @@ +import { useMedia } from '.'; + +import { WebRTC } from '@business/services'; + +import { useMediaInfo, useMediaStreamStore } from '@stores/zustandStores'; + +export function useMediaStream() { + const webRTC = WebRTC.getInstance(); + const mediaInfoChannel = webRTC.getDataChannel('mediaInfoChannel'); + + const { getAudioStream, getVideoStream } = useMedia(); + + const { localStream, remoteStream, setLocalStream } = useMediaStreamStore(state => ({ + localStream: state.localStream, + remoteStream: state.remoteStream, + setLocalStream: state.setLocalStream, + })); + + const { myVideoEnabled, myMicEnabled, toggleMyVideo, toggleMyMic, setSelectedCameraID, setSelectedAudioID } = + useMediaInfo(state => ({ + myVideoEnabled: state.myVideoOn, + myMicEnabled: state.myMicOn, + toggleMyVideo: state.toggleMyVideo, + toggleMyMic: state.toggleMyMic, + setSelectedCameraID: state.setSelectedCameraID, + setSelectedAudioID: state.setSelectedAudioID, + })); + + const addTracks = (stream: MediaStream, replacePeerconnection?: boolean) => { + stream.getTracks().forEach(track => { + localStream.addTrack(track); + replacePeerconnection && webRTC.addTrack2PeerConnection(localStream, track); + }); + }; + + const replacePeerconnectionTrack = (type: 'video' | 'audio') => { + const firstTrack = type === 'video' ? localStream.getVideoTracks()[0] : localStream.getAudioTracks()[0]; + webRTC.replacePeerconnectionSendersTrack(type, firstTrack); + }; + + const stopTracks = (type: 'video' | 'audio') => { + localStream.getTracks().forEach(track => { + if (track.kind === type) { + track.stop(); + localStream.removeTrack(track); + } + }); + }; + + const toggleMediaOnOff = async ({ + type, + replacePeerconnection = true, + }: { + type: 'audio' | 'video'; + replacePeerconnection?: boolean; + }) => { + const mediaEnabled = type === 'video' ? myVideoEnabled : myMicEnabled; + const toggleMediaState = type === 'video' ? toggleMyVideo : toggleMyMic; + + if (mediaInfoChannel?.readyState === 'open') { + mediaInfoChannel.send(JSON.stringify([{ type, onOrOff: !mediaEnabled }])); + } + + toggleMediaState(); + + if (mediaEnabled) { + stopTracks(type); + } else { + const stream = type === 'video' ? await getVideoStream() : await getAudioStream(); + + setLocalStream(localStream); + addTracks(stream, replacePeerconnection); + } + }; + + const changeMediaTrack = async ({ + type, + id, + replacePeerconnection, + }: { + type: 'audio' | 'video'; + id?: string; + replacePeerconnection?: boolean; + }) => { + const stream = type === 'audio' ? await getAudioStream({ audioID: id }) : await getVideoStream({ cameraID: id }); + + if (id) { + type === 'audio' ? setSelectedAudioID(id) : setSelectedCameraID(id); + } + + stopTracks(type); + addTracks(stream, replacePeerconnection); + replacePeerconnectionTrack(type); + }; + + const disconnectMediaStream = () => { + localStream.getTracks().forEach(track => { + track.stop(); + localStream.removeTrack(track); + }); + }; + + return { + toggleMediaOnOff, + changeMediaTrack, + disconnectMediaStream, + replacePeerconnectionTrack, + localStream, + remoteStream, + }; +} diff --git a/frontend/src/business/hooks/webRTC/useStreamVideoRef.tsx b/frontend/src/business/hooks/webRTC/useStreamVideoRef.tsx deleted file mode 100644 index 97d3ce19..00000000 --- a/frontend/src/business/hooks/webRTC/useStreamVideoRef.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect, useRef } from 'react'; - -import { WebRTC } from '@business/services'; - -export function useStreamVideoRef() { - const webRTC = WebRTC.getInstance(); - - const localVideoRef = useRef(null); - const remoteVideoRef = useRef(null); - - useEffect(() => { - if (!remoteVideoRef.current || !webRTC.getRemoteStream()) { - return; - } - remoteVideoRef.current.srcObject = webRTC.getRemoteStream() as MediaStream; - }, [webRTC.getRemoteStream()?.id]); - - useEffect(() => { - const existRemoteVideo = remoteVideoRef.current; - const existRemoteStream = webRTC.getRemoteStream(); - const remoteStreamChanged = (remoteVideoRef.current?.srcObject as MediaStream)?.id !== webRTC.getRemoteStream()?.id; - if (!existRemoteVideo || !existRemoteStream || !remoteStreamChanged) { - return; - } - - remoteVideoRef.current.srcObject = webRTC.getRemoteStream() as MediaStream; - }, [remoteVideoRef.current]); - - return { localVideoRef, remoteVideoRef }; -} diff --git a/frontend/src/business/hooks/webRTC/useWebRTC.ts b/frontend/src/business/hooks/webRTC/useWebRTC.ts index 81d7196f..1d5ee17c 100644 --- a/frontend/src/business/hooks/webRTC/useWebRTC.ts +++ b/frontend/src/business/hooks/webRTC/useWebRTC.ts @@ -1,32 +1,48 @@ -import { useDataChannel, useMedia } from '.'; +import { useDataChannel } from '.'; import { WebRTC } from '@business/services'; import { initSignalingSocket } from '@business/services'; import { HumanSocketManager } from '@business/services/SocketManager'; +import { useMediaStreamStore } from '@stores/zustandStores'; + export function useWebRTC() { const humanSocket = HumanSocketManager.getInstance(); const webRTC = WebRTC.getInstance(humanSocket); - const { getLocalStream } = useMedia(); - const { initDataChannels } = useDataChannel(); + const { localStream, setRemoteStream } = useMediaStreamStore(state => ({ + localStream: state.localStream, + setRemoteStream: state.setRemoteStream, + })); + + const resetWebRTCDataChannel = (roomName: string) => { + webRTC.closeRTCPeerConnection(); + webRTC.closeDataChannels(); + webRTC.connectRTCPeerConnection({ roomName, onTrack: e => setRemoteStream(e.streams[0]) }); + initDataChannels(); + localStream.getTracks().forEach(track => { + webRTC.addTrack2PeerConnection(localStream, track); + }); + }; + const startWebRTC = async ({ roomName }: { roomName: string }) => { if (webRTC.isConnectedPeerConnection()) { return false; } - const stream = await getLocalStream(); - webRTC.setLocalStream(stream); initSignalingSocket({ roomName, - onExitUser: () => resetWebRTCDataChannel(webRTC, initDataChannels, roomName), + onExitUser: () => resetWebRTCDataChannel(roomName), + }); + + webRTC.connectRTCPeerConnection({ + roomName, + onTrack: e => setRemoteStream(e.streams[0]), }); - webRTC.connectRTCPeerConnection(roomName); initDataChannels(); - webRTC.addTracks(); }; const endWebRTC = () => { @@ -38,13 +54,6 @@ export function useWebRTC() { return { startWebRTC, endWebRTC, + resetWebRTCDataChannel, }; } - -export function resetWebRTCDataChannel(webRTC: WebRTC, initDataChannels: () => void, roomName: string) { - webRTC.closeRTCPeerConnection(); - webRTC.closeDataChannels(); - webRTC.connectRTCPeerConnection(roomName); - initDataChannels(); - webRTC.addTracks(); -} diff --git a/frontend/src/business/services/Media.ts b/frontend/src/business/services/Media.ts index c80c1099..46a4f851 100644 --- a/frontend/src/business/services/Media.ts +++ b/frontend/src/business/services/Media.ts @@ -18,11 +18,11 @@ export async function getMediaDeviceOptions() { } export async function getUserMediaStream({ - audio, - video, + audio = false, + video = false, }: { - audio: boolean | MediaTrackConstraints | undefined; - video: boolean | MediaTrackConstraints | undefined; + audio?: boolean | MediaTrackConstraints | undefined; + video?: boolean | MediaTrackConstraints | undefined; }) { return await navigator.mediaDevices.getUserMedia({ audio, video }); } diff --git a/frontend/src/business/services/Socket.ts b/frontend/src/business/services/Socket.ts index 96fbb2ef..6543521a 100644 --- a/frontend/src/business/services/Socket.ts +++ b/frontend/src/business/services/Socket.ts @@ -4,63 +4,64 @@ import { ERROR_MESSAGE } from '@constants/messages'; import { HumanSocketManager } from './SocketManager'; +interface WebRTCConnectionSetupParams { + description?: RTCSessionDescription; + candidate?: RTCIceCandidate; +} + interface initSignalingSocketParams { roomName: string; - onExitUser: () => void; + onExitUser?: () => void; } export const initSignalingSocket = ({ roomName, onExitUser }: initSignalingSocketParams) => { - const webRTC = WebRTC.getInstance(HumanSocketManager.getInstance()); const socketManager = HumanSocketManager.getInstance(); - socketManager.on('welcome', async (users: { id: string }[]) => { - await sendCreatedOffer(users, roomName); - }); - - socketManager.on('offer', async (sdp: RTCSessionDescription) => { - await sendCreatedAnswer(sdp, roomName); - }); - - socketManager.on('answer', async (sdp: RTCSessionDescription) => { - await webRTC.setRemoteDescription(sdp); - }); + // 최초 접속 시, welcome 이벤트를 받고 offer를 생성하여 상대방에게 보냄 + socketManager.on('welcome', () => sendCreatedSDP(roomName, 'offer')); - socketManager.on('candidate', async (candidate: RTCIceCandidate) => { - await webRTC.addIceCandidate(candidate); - }); + socketManager.on('connection', (params: WebRTCConnectionSetupParams) => + handleWebRTCConnectionSetup({ ...params, roomName }), + ); socketManager.on('roomFull', () => { alert(ERROR_MESSAGE.FULL_ROOM); }); socketManager.on('userExit', async () => { - onExitUser(); + onExitUser?.(); }); }; -export async function sendCreatedOffer(users: { id: string }[], roomName: string) { - if (users.length === 0) { - return; - } - const webRTC = WebRTC.getInstance(); +export async function sendCreatedSDP(roomName: string, type: 'offer' | 'answer') { + const webRTC = WebRTC.getInstance(HumanSocketManager.getInstance()); const socketManager = HumanSocketManager.getInstance(); - const sdp = await webRTC.createOffer(); - + const sdp = type === 'offer' ? await webRTC.createOffer() : await webRTC.createAnswer(); await webRTC.setLocalDescription(sdp); - socketManager.emit('offer', sdp, roomName); + const description = webRTC.getPeerConnection()?.localDescription; + socketManager.emit('connection', { roomName, description }); } -export async function sendCreatedAnswer(sdp: RTCSessionDescription, roomName: string) { - const webRTC = WebRTC.getInstance(); - const socketManager = HumanSocketManager.getInstance(); - - await webRTC.setRemoteDescription(sdp); - - const answerSdp = await webRTC.createAnswer(); +export async function handleWebRTCConnectionSetup({ + description, + candidate, + roomName, +}: { roomName: string } & WebRTCConnectionSetupParams) { + const webRTC = WebRTC.getInstance(HumanSocketManager.getInstance()); - webRTC.setLocalDescription(answerSdp); + // offer & answer를 주고받는 과정 + if (description) { + await webRTC.setRemoteDescription(description); - socketManager.emit('answer', answerSdp, roomName); + // 상대방이 보낸 offer에 대해 answer를 생성하고, 이를 다시 상대방에게 보냄 + if (description.type === 'offer') { + sendCreatedSDP(roomName, 'answer'); + } + } + // 위의 과정이 끝나고 ice candidate를 주고받는 과정 + else if (candidate) { + await webRTC.addIceCandidate(candidate); + } } diff --git a/frontend/src/business/services/WebRTC.ts b/frontend/src/business/services/WebRTC.ts index 2f28a2ba..8198c33f 100644 --- a/frontend/src/business/services/WebRTC.ts +++ b/frontend/src/business/services/WebRTC.ts @@ -22,38 +22,27 @@ export class WebRTC { return this.instance; } - private localStream: MediaStream | undefined; - private remoteStream: MediaStream | undefined; - private peerConnection: RTCPeerConnection | null = null; + private peerConnection: RTCPeerConnection | undefined; private dataChannels: Map = new Map(); private nextDataChannelId = 0; - getLocalStream = () => this.localStream; - getFirstVideoTrack = () => this.localStream?.getVideoTracks()[0]; - getFirstAudioTrack = () => this.localStream?.getAudioTracks()[0]; - - getRemoteStream = () => this.remoteStream; getPeerConnection = () => this.peerConnection; getDataChannels = () => this.dataChannels; getDataChannel = (key: RTCDataChannelKey) => this.dataChannels.get(key); - // getSocketManager = () => this.socketManager; - // getNextDataChannelId = () => this.nextDataChannelId; public resetForTesting() { - this.localStream = undefined; - this.remoteStream = undefined; - this.peerConnection = null; + this.peerConnection = undefined; this.dataChannels = new Map(); this.nextDataChannelId = 0; } - public setLocalStream = (stream: MediaStream) => { - this.localStream = stream; - }; - public setRemoteStream = (stream: MediaStream) => { - this.remoteStream = stream; - }; - public connectRTCPeerConnection = (roomName: string) => { + public connectRTCPeerConnection = ({ + roomName, + onTrack, + }: { + roomName: string; + onTrack: (e: RTCTrackEvent) => void; + }) => { const devIceServerConfig = [{ urls: iceServers }]; this.peerConnection = new RTCPeerConnection({ @@ -61,16 +50,28 @@ export class WebRTC { iceServers: import.meta.env.MODE === 'development' ? devIceServerConfig : devIceServerConfig, }); - this.addRTCPeerConnectionEventListener('track', e => { - this.setRemoteStream(e.streams[0]); + this.peerConnection.addEventListener('track', e => { + onTrack(e); }); - this.addRTCPeerConnectionEventListener('icecandidate', e => { + // 이 리스너는 재협상이 필요한 상황에서 호출됨 + this.peerConnection.addEventListener('negotiationneeded', async () => { + // 만약 아직 peerConnection이 초기화되지 않았거나, 연결된적이 없다면 리턴함 + if (this.peerConnection?.signalingState !== 'stable' || !this.peerConnection?.remoteDescription) { + return; + } + + const offer = await this.peerConnection?.createOffer(); + await this.setLocalDescription(offer); + this.socketManager.emit('connection', { roomName, description: this.peerConnection?.localDescription }); + }); + + this.peerConnection.addEventListener('icecandidate', e => { if (!e.candidate) { return; } - this.socketManager.emit('candidate', e.candidate, roomName); + this.socketManager.emit('connection', { roomName, candidate: e.candidate }); }); }; @@ -104,17 +105,6 @@ export class WebRTC { await this.peerConnection?.setLocalDescription(sdp); }; - public addRTCPeerConnectionEventListener = ( - type: K, - listener: (this: RTCPeerConnection, ev: RTCPeerConnectionEventMap[K]) => any, - options?: boolean | AddEventListenerOptions, - ): void => { - if (!this.peerConnection) { - throw new Error('addRTCPeerConnectionEventListener 도중 에러, peerConnection이 없습니다.'); - } - this.peerConnection?.addEventListener(type, listener, options); - }; - public addIceCandidate = async (candidate?: RTCIceCandidateInit) => { if (!candidate) { throw new Error('addIceCandidate 도중 에러, candidate가 없습니다.'); @@ -124,7 +114,7 @@ export class WebRTC { public closeRTCPeerConnection = () => { this.peerConnection?.close(); - this.peerConnection = null; + this.peerConnection = undefined; }; public isConnectedPeerConnection = () => { @@ -160,25 +150,12 @@ export class WebRTC { this.nextDataChannelId = 0; }; - public addTracks = () => { - if (this.localStream === undefined) { - return; - } - - this.localStream.getTracks().forEach(track => { - this.peerConnection?.addTrack(track, this.localStream!); - }); - }; - - public replacePeerconnectionVideoTrack2NowLocalStream = () => { - const nowVideoTrack = this.localStream?.getVideoTracks()[0]; - const sender = this.peerConnection?.getSenders().find(sender => sender.track?.kind === 'video'); - sender?.replaceTrack(nowVideoTrack!); + public addTrack2PeerConnection = (stream: MediaStream, track: MediaStreamTrack) => { + this.peerConnection?.addTrack(track, stream); }; - public replacePeerconnectionAudioTrack2NowLocalStream = () => { - const nowAudioTrack = this.localStream?.getAudioTracks()[0]; - const sender = this.peerConnection?.getSenders().find(sender => sender.track?.kind === 'audio'); - sender?.replaceTrack(nowAudioTrack!); + public replacePeerconnectionSendersTrack = (type: 'video' | 'audio', track: MediaStreamTrack) => { + const sender = this.peerConnection?.getSenders().find(sender => sender.track?.kind === type); + sender?.replaceTrack(track); }; } diff --git a/frontend/src/business/services/__mocks__/WebRTC.ts b/frontend/src/business/services/__mocks__/WebRTC.ts index a4c895f8..68008222 100644 --- a/frontend/src/business/services/__mocks__/WebRTC.ts +++ b/frontend/src/business/services/__mocks__/WebRTC.ts @@ -1,56 +1,20 @@ -// export default { -// getInstance: vi.fn().mockReturnValue({ -// getLocalStream: vi.fn(), -// getFirstVideoTrack: vi.fn(), -// getFirstAudioTrack: vi.fn(), -// getRemoteStream: vi.fn(), -// getPeerConnection: vi.fn(), -// getDataChannels: vi.fn(), -// getDataChannel: vi.fn(), -// resetForTesting: vi.fn(), -// setLocalStream: vi.fn(), -// setRemoteStream: vi.fn(), -// connectRTCPeerConnection: vi.fn(), -// createOffer: vi.fn().mockResolvedValue(Promise.resolve('offer')), -// createAnswer: vi.fn().mockResolvedValue(Promise.resolve('answer')), -// setRemoteDescription: vi.fn(), -// setLocalDescription: vi.fn(), -// addRTCPeerConnectionEventListener: vi.fn(), -// addIceCandidate: vi.fn(), -// closeRTCPeerConnection: vi.fn(), -// isConnectedPeerConnection: vi.fn(), -// addDataChannel: vi.fn(), -// closeDataChannels: vi.fn(), -// addTracks: vi.fn(), -// replacePeerconnectionVideoTrack2NowLocalStream: vi.fn(), -// replacePeerconnectionAudioTrack2NowLocalStream: vi.fn(), -// }), -// }; export const WebRTC = { getInstance: vi.fn().mockReturnValue({ - getLocalStream: vi.fn(), - getFirstVideoTrack: vi.fn(), - getFirstAudioTrack: vi.fn(), - getRemoteStream: vi.fn(), getPeerConnection: vi.fn(), getDataChannels: vi.fn(), getDataChannel: vi.fn(), resetForTesting: vi.fn(), - setLocalStream: vi.fn(), - setRemoteStream: vi.fn(), connectRTCPeerConnection: vi.fn(), createOffer: vi.fn().mockResolvedValue(Promise.resolve('offer')), createAnswer: vi.fn().mockResolvedValue(Promise.resolve('answer')), setRemoteDescription: vi.fn(), setLocalDescription: vi.fn(), - addRTCPeerConnectionEventListener: vi.fn(), addIceCandidate: vi.fn(), closeRTCPeerConnection: vi.fn(), isConnectedPeerConnection: vi.fn(), addDataChannel: vi.fn(), closeDataChannels: vi.fn(), - addTracks: vi.fn(), - replacePeerconnectionVideoTrack2NowLocalStream: vi.fn(), - replacePeerconnectionAudioTrack2NowLocalStream: vi.fn(), + addTrack2PeerConnection: vi.fn(), + replacePeerconnectionSendersTrack: vi.fn(), }), }; diff --git a/frontend/src/business/services/__tests__/Media.spec.ts b/frontend/src/business/services/__tests__/Media.spec.ts index 7c0d02b6..f6b3302c 100644 --- a/frontend/src/business/services/__tests__/Media.spec.ts +++ b/frontend/src/business/services/__tests__/Media.spec.ts @@ -1,13 +1,6 @@ import { getAudioInputOptions, getCameraInputOptions, getMediaDeviceOptions, getUserMediaStream } from '../Media'; -import { __setMockNavigatorWithTracks, mockMediaStream } from '@mocks/webRTC'; - -const createFakeEnumerateDevice = (kind: 'videoinput' | 'audioinput', id: string): any => ({ - kind, - deviceId: `fakeDeviceId${id}`, - groupId: `fakeGroupId${id}`, - label: `fakeLabel${id}`, -}); +import { __setMockNavigatorWithTracks, createFakeEnumerateDevice, mockMediaStream } from '@mocks/webRTC'; describe('Media 서비스', () => { beforeEach(() => { diff --git a/frontend/src/business/services/__tests__/Socket.spec.ts b/frontend/src/business/services/__tests__/Socket.spec.ts index 5797de29..bed4f3d4 100644 --- a/frontend/src/business/services/__tests__/Socket.spec.ts +++ b/frontend/src/business/services/__tests__/Socket.spec.ts @@ -1,9 +1,7 @@ import { WebRTC } from '..'; -import { initSignalingSocket, sendCreatedAnswer, sendCreatedOffer } from '../Socket'; +import { handleWebRTCConnectionSetup, initSignalingSocket, sendCreatedSDP } from '../Socket'; import { HumanSocketManager } from '../SocketManager'; -import { ERROR_MESSAGE } from '@constants/messages'; - vi.mock('../SocketManager'); vi.mock('..'); @@ -11,101 +9,57 @@ describe('Socket 서비스', () => { let webRTC: WebRTC; let socketManager: HumanSocketManager; + beforeAll(() => { + socketManager = HumanSocketManager.getInstance(); + webRTC = WebRTC.getInstance(socketManager); + }); + beforeEach(() => { vi.clearAllMocks(); - webRTC = WebRTC.getInstance(); - socketManager = HumanSocketManager.getInstance(); }); describe('initSignalingSocket() 함수 테스트.', () => { - it('socketManager.on() 함수는 welcome, offer, answer, candidate, roomFull, userExit와 함께 6번 호출된다', () => { + it('socketManager.on() 함수는 welcome, connection, roomFull, userExit와 함께 4번 호출된다', () => { const spyOnFn = vi.spyOn(HumanSocketManager.getInstance(), 'on'); - const testEventDatas = ['welcome', 'offer', 'answer', 'candidate', 'roomFull', 'userExit']; + const testEventDatas = ['welcome', 'connection', 'roomFull', 'userExit']; initSignalingSocket({ roomName: 'room', onExitUser: vi.fn() }); - expect(spyOnFn).toBeCalledTimes(6); + expect(spyOnFn).toBeCalledTimes(4); testEventDatas.forEach(event => { expect(spyOnFn).toBeCalledWith(event, expect.any(Function)); }); }); - describe('welcom 이벤트가 발생: sendCreatedOffer() 함수가 호출된다.', async () => { - it('users가 빈 배열이면 webRTC.createOffer() 함수가 호출되지 않는다.', async () => { - const roomName = 'alskdjf9182312'; - - sendCreatedOffer([] as any, roomName); - - expect(webRTC.createOffer).not.toBeCalled(); - }); - - it(`users가 빈 배열이 아니면 - \t 1. webRTC 의 createOffer(),setLocalDescription() 호출됨 - \t 2. socketManager.emit('answer', sdp, roonName) 호출됨 `, async () => { - const spyCreateOffer = vi.spyOn(webRTC, 'createOffer'); + it('welcom 이벤트가 발생: offer 생성 후 시그널링 서버로 전달한다.', async () => { + await sendCreatedSDP('roomName', 'offer'); - await sendCreatedOffer([{ id: '1' }], 'room'); - const sdp = spyCreateOffer.mock.results[0].value; - - expect(webRTC.createOffer).toBeCalled(); - expect(webRTC.setLocalDescription).toBeCalledWith(sdp); - expect(socketManager.emit).toBeCalledWith('offer', sdp, 'room'); + expect(webRTC.setLocalDescription).toBeCalledWith(await webRTC.createOffer()); + expect(socketManager.emit).toBeCalledWith('connection', { + roomName: 'roomName', + description: webRTC.getPeerConnection()?.localDescription, }); }); - describe('offer 이벤트가 발생: sendCreatedAnswer() 함수가 실행된다', () => { - it(`함수를 호출시 아래 실행 - \t 1. webRTC의 setRemoteDescription(), createAnswer(), setLocalDescription() 호출됨 - \t 2. socketManager.emit('answer', sdp, roonName) 호출됨`, async () => { - const spyCreateAnswer = vi.spyOn(webRTC, 'createAnswer'); - const sdp: any = 'offer'; - const roomName = 'slkdfj34lkj1'; - - await sendCreatedAnswer(sdp, roomName); - const answerSdp = spyCreateAnswer.mock.results[0].value; + describe('connection 이벤트가 발생', () => { + it('description이 존재할 때, type이 answer일 경우 remoteDescription을 설정한다.', async () => { + const description = { type: 'offer', sdp: 'sdp' } as any; + await handleWebRTCConnectionSetup({ roomName: 'roomName', description }); - expect(webRTC.setRemoteDescription).toBeCalledWith(sdp); - expect(webRTC.createAnswer).toBeCalled(); - expect(webRTC.setLocalDescription).toBeCalledWith(answerSdp); - expect(socketManager.emit).toBeCalledWith('answer', answerSdp, roomName); + expect(webRTC.setRemoteDescription).toBeCalledWith(description); }); - }); - - // 아래부터는 테스트 의미가 있나...? - // 아마 유닛 테스트를 진행해야 의미있는 테스트가 될듯.. - it('answer 이벤트 발생: webRTC.setRemoteDescription() 함수가 실행된다.', async () => { - const sdp: any = 'answer'; - await webRTC.setRemoteDescription(sdp); + it('description이 존재할 때, type이 offer일 경우, remoteDescription을 설정한 후, answer를 만들어 시그널링 서버로 보낸다.', async () => { + const description = { type: 'offer', sdp: 'sdp' } as any; + await handleWebRTCConnectionSetup({ roomName: 'roomName', description }); - expect(webRTC.setRemoteDescription).toBeCalledWith(sdp); - }); - - it('candidate 이벤트 발생: webRTC.addIceCandidate() 함수가 실행된다.', async () => { - const candidate: any = 'candidate'; - - await webRTC.addIceCandidate(candidate); - - expect(webRTC.addIceCandidate).toBeCalledWith(candidate); - }); - - it('roomFull 이벤트 발생: alert(ERROR_MESSAGE.FULL_ROOM) 함수가 실행된다.', () => { - const originAlert = window.alert; - window.alert = vi.fn(); - - alert(ERROR_MESSAGE.FULL_ROOM); - - expect(alert).toBeCalledWith(ERROR_MESSAGE.FULL_ROOM); - - window.alert = originAlert; - }); - - it('userExit 이벤트 발생: onExitUser() 함수가 실행된다.', () => { - const onExitUser = vi.fn(); - - onExitUser(); - - expect(onExitUser).toBeCalled(); + expect(webRTC.setRemoteDescription).toBeCalledWith(description); + expect(webRTC.setLocalDescription).toBeCalledWith(await webRTC.createAnswer()); + expect(socketManager.emit).toBeCalledWith('connection', { + roomName: 'roomName', + description: webRTC.getPeerConnection()?.localDescription, + }); + }); }); }); }); diff --git a/frontend/src/business/services/__tests__/WebRTC.spec.ts b/frontend/src/business/services/__tests__/WebRTC.spec.ts index 7ca5b34f..9b9901d7 100644 --- a/frontend/src/business/services/__tests__/WebRTC.spec.ts +++ b/frontend/src/business/services/__tests__/WebRTC.spec.ts @@ -1,29 +1,26 @@ import { WebRTC } from '../WebRTC'; -import { mockEventType, mockListener, mockOptions, setupEventListener } from '@mocks/event'; +import { setupEventListener } from '@mocks/event'; import { mockSocketManager } from '@mocks/socket'; import { __setMockMediaStreamTracks, createFakeDataChannel, createFakeMediaStreamTrack, - mockMediaStream, mockPeerConnection, mockRTCDataChannelKeys, mockRoomName, mockSdp, - mockSender, } from '@mocks/webRTC'; describe('WebRTC.ts', () => { - it('aa', () => expect(1).toBe(1)); - let instance: WebRTC; + let webRTC: WebRTC; let events: any = {}; beforeEach(() => { global.RTCPeerConnection = vi.fn().mockImplementation(() => mockPeerConnection) as any; - instance = WebRTC.getInstance(mockSocketManager)!; + webRTC = WebRTC.getInstance(mockSocketManager)!; events = {}; setupEventListener(events, mockPeerConnection); - instance.resetForTesting(); + webRTC.resetForTesting(); __setMockMediaStreamTracks([ createFakeMediaStreamTrack('video', 'video1'), createFakeMediaStreamTrack('video', 'video2'), @@ -34,44 +31,40 @@ describe('WebRTC.ts', () => { afterEach(() => { vi.clearAllMocks(); }); + it('해당 모듈이 존재함', () => { expect(WebRTC).toBeDefined(); }); + it('이 모듈은 싱글톤 이어야함', () => { const instance1 = WebRTC.getInstance(); const instance2 = WebRTC.getInstance(); expect(instance1).toEqual(instance2); }); - describe('setLocalStream 메서드', () => { - it('localStream을 설정해야함', () => { - instance.setLocalStream(mockMediaStream); - expect(instance.getLocalStream()).toEqual(mockMediaStream); - }); - }); - describe('setRemoteStream 메서드', () => { - it('remoteStream을 설정해야함', () => { - instance.setRemoteStream(mockMediaStream); - expect(instance.getRemoteStream()).toEqual(mockMediaStream); - }); - }); + describe('connectRTCPeerConnection 메서드', () => { it('호출시: RTCPeerConnection을 생성', () => { - instance.connectRTCPeerConnection(mockRoomName); - expect(instance.getPeerConnection()).toBe(mockPeerConnection); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); + expect(webRTC.getPeerConnection()).toBe(mockPeerConnection); }); + it('호출시: track 이벤트 리스너를 추가', () => { - instance.connectRTCPeerConnection(mockRoomName); - expect(instance.getPeerConnection()?.addEventListener).toBeCalledWith('track', expect.any(Function), undefined); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); + expect(webRTC.getPeerConnection()?.addEventListener).toBeCalledWith('track', expect.any(Function)); }); + it('호출시: icecandidate 이벤트 리스너를 추가', () => { - instance.connectRTCPeerConnection(mockRoomName); - expect(instance.getPeerConnection()?.addEventListener).toBeCalledWith( - 'icecandidate', - expect.any(Function), - undefined, - ); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); + expect(webRTC.getPeerConnection()?.addEventListener).toBeCalledWith('icecandidate', expect.any(Function)); + }); + + it('호출시: negotiationneeded 이벤트 리스너를 추가', () => { + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); + expect(webRTC.getPeerConnection()?.addEventListener).toBeCalledWith('negotiationneeded', expect.any(Function)); }); - it('track 이벤트 발생: setRemoteStream 메서드를 호출 해야함', () => { + + it('track 이벤트 발생: 인수로 들어온 onTrack이벤트를 발생시킴', () => { + const onTrack = vi.fn(); const event = { streams: [ createFakeMediaStreamTrack('video', 'video1'), @@ -80,97 +73,111 @@ describe('WebRTC.ts', () => { createFakeMediaStreamTrack('audio', 'audio2'), ], }; - instance.connectRTCPeerConnection(mockRoomName); + + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack }); events.track(event); - expect(instance.getRemoteStream()).toEqual(event.streams[0]); + + expect(onTrack).toBeCalledWith(event); }); + it('icecandidate 이벤트 발생: candidate가 없으면 아무것도 하지 않음', () => { - instance.connectRTCPeerConnection(mockRoomName); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); events.icecandidate({ candidate: null }); expect(mockSocketManager.emit).not.toBeCalled(); }); + it('icecandidate 이벤트 발생: candidate가 있으면 socketManager.emit을 호출해야함', () => { - instance.connectRTCPeerConnection(mockRoomName); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); events.icecandidate({ candidate: 'candidate' }); - expect(mockSocketManager.emit).toBeCalledWith('candidate', 'candidate', mockRoomName); + expect(mockSocketManager.emit).toBeCalledWith('connection', { roomName: mockRoomName, candidate: 'candidate' }); + }); + + it('negotiationneeded 이벤트 발생: 연결된적 없다면 아무것도 하지않음', async () => { + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); + events.negotiationneeded(); + expect(mockPeerConnection.createOffer).not.toBeCalled(); + }); + + it('negotiationneeded 이벤트 발생: 연결된적이 있으면 createOffer를 호출해야함', async () => { + mockPeerConnection.signalingState = 'stable'; + mockPeerConnection.remoteDescription = 'remoteDescription'; + mockPeerConnection.createOffer.mockResolvedValue(mockSdp); + + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); + events.negotiationneeded(); + + expect(mockPeerConnection.createOffer).toBeCalled(); }); }); + describe('createOffer 메서드', () => { it('peerConnection이 없음: 에러를 던짐', async () => { - expect(instance.createOffer()).rejects.toThrowError(); + expect(webRTC.createOffer()).rejects.toThrowError(); }); + it('peerConnection이 있음 createOffer결과를 리턴', async () => { - instance.connectRTCPeerConnection(mockRoomName); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); mockPeerConnection.createOffer.mockResolvedValue(mockSdp); - expect(instance.createOffer()).resolves.toBe(mockSdp); + expect(webRTC.createOffer()).resolves.toBe(mockSdp); }); }); describe('createAnswer 메서드', () => { it('peerConnection이 없음: 에러 발생', async () => { - expect(instance.createAnswer()).rejects.toThrowError(); + expect(webRTC.createAnswer()).rejects.toThrowError(); }); it('peerConnection이 있음: createAnswer결과를 리턴', async () => { - instance.connectRTCPeerConnection(mockRoomName); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); mockPeerConnection.createAnswer.mockResolvedValue(mockSdp); - expect(instance.createAnswer()).resolves.toBe(mockSdp); + expect(webRTC.createAnswer()).resolves.toBe(mockSdp); }); }); describe('setRemoteDescription 메서드', () => { it('peerConnection이 없음: 에러 발생', async () => { - expect(instance.setRemoteDescription()).rejects.toThrowError(); + expect(webRTC.setRemoteDescription()).rejects.toThrowError(); }); it('peerConnection이 있음: setRemoteDescription을 호출해야함', async () => { - instance.connectRTCPeerConnection(mockRoomName); - instance.setRemoteDescription(mockSdp); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); + webRTC.setRemoteDescription(mockSdp); expect(mockPeerConnection.setRemoteDescription).toBeCalledWith(mockSdp); }); }); - describe('addRTCPeerConnectionEventListener 메서드', () => { - it('peerConnection 없음: 에러 발생', () => { - expect(() => instance.addRTCPeerConnectionEventListener(mockEventType, vi.fn())).toThrowError(); - }); - it('peerConnection 있음: peerConnection.addEventListener를 호출해야함', () => { - instance.connectRTCPeerConnection(mockRoomName); - instance.addRTCPeerConnectionEventListener(mockEventType, mockListener, mockOptions); - expect(mockPeerConnection.addEventListener).toBeCalledWith(mockEventType, mockListener, mockOptions); - }); - }); + describe('addIceCandidate 메서드', () => { it('peerConnection 없음: 에러 발생', async () => { - await expect(instance.addIceCandidate()).rejects.toThrowError(); + await expect(webRTC.addIceCandidate()).rejects.toThrowError(); }); it('peerConnection 있음: addIceCandidate를 호출해야함', async () => { - instance.connectRTCPeerConnection(mockRoomName); - instance.addIceCandidate(mockSdp); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); + webRTC.addIceCandidate(mockSdp); expect(mockPeerConnection.addIceCandidate).toBeCalledWith(mockSdp); }); }); describe('closeRTCPeerConnection 메서드', () => { it('호출시 peerConnection.close() 호출, peerConnection = null로 초기화', () => { - instance.connectRTCPeerConnection(mockRoomName); - instance.closeRTCPeerConnection(); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); + webRTC.closeRTCPeerConnection(); expect(mockPeerConnection.close).toBeCalled(); - expect(instance.getPeerConnection()).toBe(null); + expect(webRTC.getPeerConnection()).toBe(undefined); }); }); describe('isConnectedPeerConnection 메서드', () => { it('peerConnection이 없음 false를 리턴', () => { - expect(instance.isConnectedPeerConnection()).toBe(false); + expect(webRTC.isConnectedPeerConnection()).toBe(false); }); it('peerConnection이 있고, iceConnectionState가 connected: true를 리턴', () => { - instance.connectRTCPeerConnection(mockRoomName); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); mockPeerConnection.iceConnectionState = 'connected'; - expect(instance.isConnectedPeerConnection()).toBe(true); + expect(webRTC.isConnectedPeerConnection()).toBe(true); }); it('peerConnection이 있고, iceConnectionState가 connected 아님: false를 리턴', () => { - instance.connectRTCPeerConnection(mockRoomName); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); mockPeerConnection.iceConnectionState = ''; - expect(instance.isConnectedPeerConnection()).toBe(false); + expect(webRTC.isConnectedPeerConnection()).toBe(false); }); }); describe('addDataChannel 메서드', () => { it('peerConnection이 없음: 에러 발생', () => { - expect(() => instance.addDataChannel(mockRTCDataChannelKeys[0])).toThrowError(); + expect(() => webRTC.addDataChannel(mockRTCDataChannelKeys[0])).toThrowError(); }); it(`peerConnection이 있고, 정상적으로 데이터 채널을 생성: \t 1. createDataChannel을 호출해서 데이터 채널을 생성 @@ -178,17 +185,17 @@ describe('WebRTC.ts', () => { \t 3. 생성된 데이터 채널을 리턴함`, () => { const mockKey = mockRTCDataChannelKeys[0]; const mockDataChannel = createFakeDataChannel(0); - instance.connectRTCPeerConnection(mockRoomName); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); mockPeerConnection.createDataChannel.mockReturnValue(mockDataChannel); - const spy = vi.spyOn(instance, 'addDataChannel'); - instance.addDataChannel(mockRTCDataChannelKeys[0]); + const spy = vi.spyOn(webRTC, 'addDataChannel'); + webRTC.addDataChannel(mockRTCDataChannelKeys[0]); // 1. peerConnection.createDataChannel로 데이터 채널을 생성함 expect(mockPeerConnection.createDataChannel).toBeCalledWith(mockRTCDataChannelKeys[0], { negotiated: true, id: 0, }); // 2. 데이터 채널을 생성후 dataChannels에 추가됨 - const dataChannels = instance.getDataChannel(mockKey); + const dataChannels = webRTC.getDataChannel(mockKey); expect(dataChannels).toBe(mockDataChannel); // 3. 데이터 채널을 생성후 리턴 expect(spy).toReturnWith(mockDataChannel); @@ -196,62 +203,22 @@ describe('WebRTC.ts', () => { }); describe('closeDataChannels 메서드', () => { it('호출시: dataChannel.close() 호출: 각 데이터채널의 close() 호출, 데이채널', () => { - instance.connectRTCPeerConnection(mockRoomName); + webRTC.connectRTCPeerConnection({ roomName: mockRoomName, onTrack: vi.fn() }); const mockDataChanels: any[] = []; // 데이터 채널을 두개 추가함 [0, 1].forEach(id => { mockPeerConnection.createDataChannel.mockReturnValue(createFakeDataChannel(id)); - mockDataChanels.push(instance.addDataChannel(mockRTCDataChannelKeys[id])); + mockDataChanels.push(webRTC.addDataChannel(mockRTCDataChannelKeys[id])); }); // 데이터 채널이 두개 추가됨 - expect(instance.getDataChannels().size).toBe(2); - instance.closeDataChannels(); + expect(webRTC.getDataChannels().size).toBe(2); + webRTC.closeDataChannels(); // 데이터 채널의 close 메서드가 각각 호출됨 mockDataChanels.forEach(mockDataChannel => { expect(mockDataChannel.close).toBeCalledTimes(1); }); // 데이터 채널이 모두 닫힘 - expect(instance.getDataChannels().size).toBe(0); - }); - }); - describe('addTracks 메서드', () => { - it('localStream이 없음: 아무것도 하지 않음', () => { - instance.addTracks(); - expect(mockPeerConnection.addTrack).not.toBeCalled(); - }); - it('localStream이 있음: localStream.getTracks를 forEach로 돌며 peerConnection.addTrack(track, localStream)실행 ', () => { - instance.connectRTCPeerConnection(mockRoomName); - instance.setLocalStream(mockMediaStream); - instance.addTracks(); - mockMediaStream.getTracks().forEach((track: any) => { - expect(mockPeerConnection.addTrack).toBeCalledWith(track, mockMediaStream); - }); - }); - }); - describe('replacePeerconnectionVideoTrack2NowLocalStream 메서드', () => { - it('localStream이 없음: 아무것도 하지 않음', () => { - instance.replacePeerconnectionVideoTrack2NowLocalStream(); - expect(mockPeerConnection.addTrack).not.toBeCalled(); - }); - it('localStream이 있음: sender의 replaceTrack(firstVideoTrack)이 호출됨', () => { - instance.connectRTCPeerConnection(mockRoomName); - instance.setLocalStream(mockMediaStream); - instance.replacePeerconnectionVideoTrack2NowLocalStream(); - const firstVideoTrack = mockMediaStream.getVideoTracks()[0]; - expect(mockSender.video.replaceTrack).toBeCalledWith(firstVideoTrack); - }); - }); - describe('replacePeerconnectionAudioTrack2NowLocalStream 메서드', () => { - it('localStream이 없음: 아무것도 하지 않음', () => { - instance.replacePeerconnectionAudioTrack2NowLocalStream(); - expect(mockPeerConnection.addTrack).not.toBeCalled(); - }); - it('localStream이 있음: sender의 replaceTrack(firstVideoTrack)이 호출됨', () => { - instance.connectRTCPeerConnection(mockRoomName); - instance.setLocalStream(mockMediaStream); - instance.replacePeerconnectionAudioTrack2NowLocalStream(); - const firstAudioTrack = mockMediaStream.getAudioTracks()[0]; - expect(mockSender.audio.replaceTrack).toBeCalledWith(firstAudioTrack); + expect(webRTC.getDataChannels().size).toBe(0); }); }); }); diff --git a/frontend/src/components/common/TarotSpread/TarotSpread.tsx b/frontend/src/components/common/TarotSpread/TarotSpread.tsx index 8a66fc91..6591f046 100644 --- a/frontend/src/components/common/TarotSpread/TarotSpread.tsx +++ b/frontend/src/components/common/TarotSpread/TarotSpread.tsx @@ -127,7 +127,7 @@ export function TarotSpread({ opened, closeSpread, pickCard }: TarotSpreadProps) return ( <> -
+
))}
-
+ ); } diff --git a/frontend/src/components/humanChatPage/CamBox/CamBox.tsx b/frontend/src/components/humanChatPage/CamBox/CamBox.tsx index 753a07b6..a21302fe 100644 --- a/frontend/src/components/humanChatPage/CamBox/CamBox.tsx +++ b/frontend/src/components/humanChatPage/CamBox/CamBox.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useSpeakerHighlighter } from '@business/hooks'; @@ -9,7 +9,7 @@ import { arrayBuffer2Blob } from '@utils/array'; import { Icon } from '@iconify/react'; interface CamBoxProps { - videoRef: React.RefObject; + stream?: MediaStream; defaultImage: 'bg-ddung' | 'bg-sponge'; profileInfo?: ProfileInfo; cameraConnected?: boolean; @@ -19,7 +19,7 @@ interface CamBoxProps { } export function CamBox({ - videoRef, + stream, defaultImage, cameraConnected, audioConnected, @@ -27,8 +27,7 @@ export function CamBox({ nickname, defaultNickname, }: CamBoxProps) { - const loading = useMemo(() => !videoRef.current?.srcObject, [videoRef.current?.srcObject]); - const hidden = useMemo(() => !cameraConnected, [cameraConnected]); + const videoRef = useRef(null); const bgImage = useMemo(() => { if (!profileInfo || !profileInfo.type) { @@ -42,17 +41,24 @@ export function CamBox({ useSpeakerHighlighter(videoRef); + useEffect(() => { + if (!videoRef.current || !stream) { + return; + } + videoRef.current.srcObject = stream; + }, [stream]); + return ( <>
- {loading &&
}{' '} + {!stream?.active &&
}