Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions frontend/src/components/__tests__/meeting/MemberVideoBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { useMeetingStore } from '@/store/useMeetingStore';
import { useMeetingSocketStore } from '@/store/useMeetingSocketStore';
import { MeetingMemberInfo } from '@/types/meeting';
import { getMembersPerPage } from '@/utils/meeting';
import { getMembersPerPage, getVideoConsumerIds } from '@/utils/meeting';
import { MemberProviderInfo } from '@/types/meeting';
import { useWindowSize } from '@/hooks/useWindowSize';
import MemberVideoBar from '@/components/meeting/MemberVideoBar';
import { Socket } from 'socket.io-client';
import { Device, Transport } from 'mediasoup-client/types';

jest.mock('@/utils/meeting', () => ({
getMembersPerPage: jest.fn(),
Expand Down Expand Up @@ -70,27 +73,66 @@ describe('<MemberVideoBar />', () => {
});

it('1페이지(6슬롯)에서는 MyVideo가 보이고, 나머지 5칸에 멤버들이 표시된다', () => {
const mockSocket = {
emitWithAck: jest.fn().mockResolvedValue({ consumerInfos: [] }),
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
} as unknown as Socket;

const mockTransport = {
id: 'test-transport',
} as unknown as Transport;

const mockDevice = {
rtpCapabilities: {},
} as unknown as Device;

useMeetingSocketStore.setState({
socket: mockSocket,
recvTransport: mockTransport,
device: mockDevice,
consumers: {},
addConsumers: jest.fn(),
});

// Given: 멤버 10명
const members: Record<string, MeetingMemberInfo> = {};
const orderedIds: string[] = [];
const producers: Record<
string,
{ cam?: MemberProviderInfo | null; mic?: MemberProviderInfo | null }
> = {};

for (let i = 1; i <= 10; i++) {
const m = createMember(i);
members[m.user_id] = m;
orderedIds.push(m.user_id);
// also add dummy producer info to ensure it gets passed
producers[m.user_id] = { cam: null, mic: null };
}
useMeetingStore.setState({ members, orderedMemberIds: orderedIds });

useMeetingStore.setState({
members,
orderedMemberIds: orderedIds,
memberProducers: producers,
});

render(<MemberVideoBar />);

// Then
// Then layout should still be correct
expect(screen.getByTestId('my-video')).toBeInTheDocument();

// 1페이지 멤버: 총 6슬롯 - MyVideo(1) = 5명
const memberVideos = screen.getAllByTestId('member-video');
expect(memberVideos).toHaveLength(5);

expect(memberVideos[0]).toHaveTextContent('Member 1');
expect(memberVideos[4]).toHaveTextContent('Member 5');

// getVideoConsumerIds should be called with producers map
expect(
(getVideoConsumerIds as jest.Mock).mock.calls.length,
).toBeGreaterThan(0);
const firstArg = (getVideoConsumerIds as jest.Mock).mock.calls[0][0];
expect(firstArg).toBe(producers);
});

it('다음 버튼을 누르면 2페이지로 이동하고 MyVideo 없이 6명의 멤버가 표시된다', () => {
Expand Down
30 changes: 16 additions & 14 deletions frontend/src/components/meeting/GlobalAudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import { getAudioConsumerIds, getConsumerInstances } from '@/utils/meeting';
import { useEffect, useRef } from 'react';

export const GlobalAudioPlayer = () => {
const { members, memberStreams, setMemberStream, removeMemberStream } =
useMeetingStore();
const {
memberProducers,
memberStreams,
setMemberStream,
removeMemberStream,
} = useMeetingStore();
const { socket, recvTransport, device, addConsumers } =
useMeetingSocketStore();

Expand All @@ -25,7 +29,7 @@ export const GlobalAudioPlayer = () => {

const currentConsumers = useMeetingSocketStore.getState().consumers;
const { newAudioConsumers } = getAudioConsumerIds(
members,
memberProducers,
currentConsumers,
);

Expand Down Expand Up @@ -59,18 +63,16 @@ export const GlobalAudioPlayer = () => {
addConsumers(newInstances);

newInstances.forEach(({ producerId, consumer }) => {
const member = Object.values(members).find(
(m) => m.mic?.provider_id === producerId,
const entry = Object.entries(memberProducers).find(
([, prod]) => prod.mic?.provider_id === producerId,
);
if (member) {
if (!member.mic?.is_paused) {
setMemberStream(
member.user_id,
'mic',
new MediaStream([consumer.track]),
);
const userId = entry ? entry[0] : undefined;
if (userId) {
const isPaused = memberProducers[userId]?.mic?.is_paused;
if (!isPaused) {
setMemberStream(userId, 'mic', new MediaStream([consumer.track]));
} else {
removeMemberStream(member.user_id, 'mic');
removeMemberStream(userId, 'mic');
}
}
});
Expand All @@ -96,7 +98,7 @@ export const GlobalAudioPlayer = () => {
return () => {
isCancelled = true;
};
}, [members, socket, recvTransport, device]);
}, [memberProducers, socket, recvTransport, device]);

return (
<div id="remote-audio-container" style={{ display: 'none' }}>
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/components/meeting/MeetingRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default function MeetingRoom() {
removeMemberStream,
setIsOpen,
setScreenSharer,
setMemberProducer,
} = useMeetingStore();
const { userId } = useUserStore();

Expand Down Expand Up @@ -262,8 +263,17 @@ export default function MeetingRoom() {
nickname: producerNickname,
is_paused: isPaused,
producer_id: producerId,
kind,
} = producerInfo;

// memberProducer 정보 업데이트
setMemberProducer(userId, producerType as 'cam' | 'mic', {
provider_id: producerId,
kind,
type: producerType as 'cam' | 'mic',
is_paused: isPaused,
});

const existingConsumer =
useMeetingSocketStore.getState().consumers[producerId];

Expand Down
51 changes: 40 additions & 11 deletions frontend/src/components/meeting/MemberVideoBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ export default function MemberVideoBar() {

const {
members,
memberProducers,
setMemberStream,
removeMemberStream,
orderedMemberIds,
pinnedMemberIds,
lastSpeakerId,
moveToFront,
setMemberProducer,
} = useMeetingStore();

const { socket, recvTransport, device, addConsumers } =
Expand Down Expand Up @@ -111,7 +113,11 @@ export default function MemberVideoBar() {
pauseConsumerIds,
visibleStreamTracks,
hiddenUserIds,
} = getVideoConsumerIds(members, targetStreamMembers, currentConsumers);
} = getVideoConsumerIds(
memberProducers,
targetStreamMembers,
currentConsumers,
);

const allResumeIds = [...resumeConsumerIds];

Expand All @@ -138,11 +144,13 @@ export default function MemberVideoBar() {
newConsumers.forEach(({ producerId, consumer }) => {
allResumeIds.push(consumer.id);

const userId = Object.values(members).find(
(m) => m.cam?.provider_id === producerId,
)?.user_id;
const userEntry = Object.entries(memberProducers).find(
([, prod]) => prod.cam?.provider_id === producerId,
);
const userId = userEntry ? userEntry[0] : undefined;
if (userId) {
if (members[userId].cam?.is_paused) {
const isPaused = memberProducers[userId]?.cam?.is_paused;
if (isPaused) {
removeMemberStream(userId, 'cam');
} else {
setMemberStream(userId, 'cam', new MediaStream([consumer.track]));
Expand All @@ -157,13 +165,15 @@ export default function MemberVideoBar() {

allResumeIds.forEach((consumerId) => {
const consumer = currentConsumers[consumerId];
const userId = Object.values(members).find(
(m) => m.cam?.provider_id === consumerId,
)?.user_id;
const userEntry = Object.entries(memberProducers).find(
([, prod]) => prod.cam?.provider_id === consumerId,
);
const userId = userEntry ? userEntry[0] : undefined;

if (consumer && userId) {
// 생산자가 진짜로 pause한 상태라면 연결하지 않음
if (members[userId].cam?.is_paused) {
const isPaused = memberProducers[userId]?.cam?.is_paused;
if (isPaused) {
removeMemberStream(userId, 'cam');
} else {
setMemberStream(userId, 'cam', new MediaStream([consumer.track]));
Expand All @@ -173,7 +183,8 @@ export default function MemberVideoBar() {

// 이미 활성화되어 있던 트랙들도 확실하게 다시 세팅
visibleStreamTracks.forEach(({ userId, track }) => {
if (members[userId].cam?.is_paused) removeMemberStream(userId, 'cam');
const isPaused = memberProducers[userId]?.cam?.is_paused;
if (isPaused) removeMemberStream(userId, 'cam');
else setMemberStream(userId, 'cam', new MediaStream([track]));
});
}
Expand All @@ -191,7 +202,7 @@ export default function MemberVideoBar() {
socket,
recvTransport,
device,
members,
memberProducers,
setMemberStream,
removeMemberStream,
addConsumers,
Expand Down Expand Up @@ -223,6 +234,13 @@ export default function MemberVideoBar() {
const onCameraProduced = async (producerInfo: ProducerInfo) => {
const { user_id: userId, producer_id: producerId, type } = producerInfo;

setMemberProducer(userId, type as 'cam' | 'mic', {
provider_id: producerId,
kind: producerInfo.kind,
type: producerInfo.type as 'cam' | 'mic',
is_paused: producerInfo.is_paused ?? false,
});

// 기존 컨슈머가 있는지 확인 (Resume 처리)
const consumers = useMeetingSocketStore.getState().consumers;
const existingConsumer = consumers[producerId];
Expand Down Expand Up @@ -251,6 +269,17 @@ export default function MemberVideoBar() {
};

const onAlertProduced = (producerInfo: ProducerInfo) => {
setMemberProducer(
producerInfo.user_id,
producerInfo.type as 'cam' | 'mic',
{
provider_id: producerInfo.producer_id,
kind: producerInfo.kind,
type: producerInfo.type as 'cam' | 'mic',
is_paused: producerInfo.is_paused,
},
);

if (producerInfo.type === 'cam' && producerInfo.is_restart) {
checkAndMoveToFront(producerInfo.user_id);
}
Expand Down
82 changes: 81 additions & 1 deletion frontend/src/store/__tests__/useMeetingStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,92 @@ describe('useMeetingStore', () => {

act(() => {
addMember(createMember());
setSpeaking('user1', true);
setSpeaking('user1', true, 6);
});

const state = useMeetingStore.getState();

expect(state.speakingMembers['user1']).toBe(true);
expect(state.lastSpeakerId).toBe('user1');
});

describe('producer metadata (memberProducers)', () => {
it('setMembers 초기화 시 cam/mic 필드가 복사된다', () => {
const { setMembers } = useMeetingStore.getState();
const member: MeetingMemberInfo = createMember({
cam: {
provider_id: 'cam-1',
kind: 'video',
type: 'cam',
is_paused: false,
},
mic: {
provider_id: 'mic-1',
kind: 'audio',
type: 'mic',
is_paused: true,
},
});

act(() => {
setMembers([member]);
});

const state = useMeetingStore.getState();
expect(state.memberProducers['user1']?.cam).toEqual(member.cam);
expect(state.memberProducers['user1']?.mic).toEqual(member.mic);
});

it('addMember는 새로운 엔트리를 생성하고 cam/mic을 채운다', () => {
const { addMember } = useMeetingStore.getState();
const member: MeetingMemberInfo = createMember({
user_id: 'user2',
cam: {
provider_id: 'cam-2',
kind: 'video',
type: 'cam',
is_paused: false,
},
});

act(() => {
addMember(member);
});

const state = useMeetingStore.getState();
expect(state.memberProducers['user2']?.cam).toEqual(member.cam);
});

it('setMemberProducer는 필드만 업데이트 한다', () => {
const { setMemberProducer, setMembers } = useMeetingStore.getState();
const member: MeetingMemberInfo = createMember({ user_id: 'user3' });

act(() => {
setMembers([member]);
setMemberProducer('user3', 'cam', {
provider_id: 'cam-x',
kind: 'video',
type: 'cam',
is_paused: true,
});
});

const state = useMeetingStore.getState();
expect(state.memberProducers['user3']?.cam?.provider_id).toBe('cam-x');
expect(state.memberProducers['user3']?.cam?.is_paused).toBe(true);
});

it('removeMember는 관련 producer 정보도 삭제한다', () => {
const { addMember, removeMember } = useMeetingStore.getState();
const member: MeetingMemberInfo = createMember({ user_id: 'user4' });

act(() => {
addMember(member);
removeMember('user4');
});

const state = useMeetingStore.getState();
expect(state.memberProducers['user4']).toBeUndefined();
});
});
});
Loading