/** * Подставной `livekit-client` для тестов: комната, участники и события под * управлением теста. В jsdom настоящий SDK не работает (нет WebRTC), поэтому * `vi.mock('livekit-client', () => import('./fakeLiveKit'))` подменяет его * целиком, а логика `lib/livekit.ts` и стора проверяется настоящая. */ export const RoomEvent = { ParticipantConnected: 'participantConnected', ParticipantDisconnected: 'participantDisconnected', TrackSubscribed: 'trackSubscribed', TrackUnsubscribed: 'trackUnsubscribed', TrackPublished: 'trackPublished', TrackUnpublished: 'trackUnpublished', TrackMuted: 'trackMuted', TrackUnmuted: 'trackUnmuted', LocalTrackPublished: 'localTrackPublished', LocalTrackUnpublished: 'localTrackUnpublished', ConnectionQualityChanged: 'connectionQualityChanged', ActiveSpeakersChanged: 'activeSpeakersChanged', Reconnecting: 'reconnecting', Reconnected: 'reconnected', Disconnected: 'disconnected', DataReceived: 'dataReceived', } as const; export const ConnectionQuality = { Excellent: 'excellent', Good: 'good', Poor: 'poor', Lost: 'lost', Unknown: 'unknown', } as const; /** Уровни качества публикации: значения совпадают с `VideoQuality` из SDK. */ export const VideoQuality = { LOW: 0, MEDIUM: 1, HIGH: 2, } as const; export const Track = { Source: { Camera: 'camera', Microphone: 'microphone', ScreenShare: 'screen_share', ScreenShareAudio: 'screen_share_audio', }, } as const; /** Дорожка: помнит, к каким элементам её подключили. */ export class FakeTrack { readonly kind: 'audio' | 'video'; readonly attached: HTMLMediaElement[] = []; /** Качество публикации, выставленное `setPublishingQuality` (тесты ждут). */ readonly publishingQuality: number[] = []; /** Сколько раз спросили отчёт WebRTC: по нему проверяют опрос статистики. */ statsRequests = 0; private readonly report: () => [string, unknown][]; constructor(kind: 'audio' | 'video', report: () => [string, unknown][] = () => []) { this.kind = kind; this.report = report; } attach(element: HTMLMediaElement): HTMLMediaElement { this.attached.push(element); return element; } detach(element: HTMLMediaElement): HTMLMediaElement[] { const index = this.attached.indexOf(element); if (index >= 0) { this.attached.splice(index, 1); } return []; } /** Отчёт WebRTC: `Map`, как настоящий `RTCStatsReport`. */ getRTCStatsReport(): Promise> { this.statsRequests += 1; return Promise.resolve(new Map(this.report())); } /** Смена качества публикации: SDK-метод `LocalVideoTrack`. */ setPublishingQuality(quality: number): void { this.publishingQuality.push(quality); } } export class FakePublication { constructor( readonly source: string, readonly track: FakeTrack, public isMuted: boolean, ) {} } type Handler = (...args: never[]) => void; /** Участник комнаты: публикации, флаги и записанные вызовы SDK. */ export class FakeParticipant { isSpeaking = false; connectionQuality: string = ConnectionQuality.Excellent; identity: string; name: string; readonly isLocal: boolean; /** Вызовы, которые проверяют тесты: `setMicrophoneEnabled(false)` и т.п. */ readonly calls: { method: string; value: unknown }[] = []; /** Отправленные data-пакеты: `{ payload, options }` каждого publishData. */ readonly data: { payload: Uint8Array; options: unknown }[] = []; /** Параметры захвата экрана: `{ enabled, options }` каждого вызова. */ readonly screenOptions: { enabled: boolean; options: unknown }[] = []; private readonly publications = new Map(); constructor( private readonly room: FakeRoom, identity: string, name: string, isLocal = false, ) { this.identity = identity; this.name = name; this.isLocal = isLocal; } get isMicrophoneEnabled(): boolean { return this.isLive(Track.Source.Microphone); } get isCameraEnabled(): boolean { return this.isLive(Track.Source.Camera); } get isScreenShareEnabled(): boolean { return this.isLive(Track.Source.ScreenShare); } getTrackPublication(source: string): FakePublication | undefined { return this.publications.get(source); } /** Публикация дорожки: используется и для своих, и для чужих участников. */ setPublication(source: string, muted = false): FakePublication { const publication = new FakePublication(source, this.newTrack(source), muted); this.publications.set(source, publication); return publication; } /** Дорожка знает про отчёт WebRTC комнаты: тесты задают его через `setStats`. */ private newTrack(source: string): FakeTrack { return new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video', () => this.room.statsEntries(), ); } removePublication(source: string): void { this.publications.delete(source); this.room.emit(RoomEvent.TrackUnsubscribed); } /** Отправка data-сообщения: тесты проверяют тему, payload и надёжность. */ publishData(payload: Uint8Array, options?: unknown): Promise { this.data.push({ payload, options }); return Promise.resolve(); } setSpeaking(speaking: boolean): void { this.isSpeaking = speaking; } private isLive(source: string): boolean { const publication = this.publications.get(source); return publication !== undefined && !publication.isMuted; } private setLocal(source: string, enabled: boolean, method: string): void { this.calls.push({ method, value: enabled }); const existing = this.publications.get(source); const track = existing?.track ?? this.newTrack(source); this.publications.set(source, new FakePublication(source, track, !enabled)); this.room.emit(RoomEvent.LocalTrackPublished); } setMicrophoneEnabled(enabled: boolean): Promise { this.setLocal(Track.Source.Microphone, enabled, 'setMicrophoneEnabled'); return Promise.resolve(); } setCameraEnabled(enabled: boolean): Promise { this.setLocal(Track.Source.Camera, enabled, 'setCameraEnabled'); return Promise.resolve(); } /** Демонстрация экрана: параметры захвата (разрешение и fps) запоминаем. */ setScreenShareEnabled(enabled: boolean, options?: unknown): Promise { this.screenOptions.push({ enabled, options }); this.setLocal(Track.Source.ScreenShare, enabled, 'setScreenShareEnabled'); return Promise.resolve(); } } export interface RemoteOptions { muted?: boolean; camera?: boolean; screen?: boolean; quality?: string; } /** Подставная комната: те же методы, что использует `lib/livekit.ts`. */ export class FakeRoom { static instances: FakeRoom[] = []; static reset(): void { FakeRoom.instances = []; } static latest(): FakeRoom { const room = FakeRoom.instances.at(-1); if (room === undefined) { throw new Error('комната LiveKit ещё не создавалась'); } return room; } readonly localParticipant: FakeParticipant; readonly remoteParticipants = new Map(); readonly connectCalls: { url: string; token: string }[] = []; readonly deviceSwitches: { kind: string; deviceId: string }[] = []; disconnectCalls = 0; startAudioCalls = 0; /** Отчёт WebRTC: пары «ключ → объект», как у `RTCStatsReport`. */ private stats: [string, unknown][] = []; private readonly handlers = new Map>(); constructor(readonly options?: unknown) { FakeRoom.instances.push(this); this.localParticipant = new FakeParticipant(this, 'user-1', 'Alice', true); } /** Отчёт WebRTC, который отдаёт `getRTCStatsReport()` своих дорожек. */ statsEntries(): [string, unknown][] { return this.stats; } setStats(entries: [string, unknown][]): void { this.stats = entries; } /** Эмуляция входящего data-пакета: событие `DataReceived` от участника. */ emitData(payload: Uint8Array, participant: FakeParticipant, topic: string): void { this.emit(RoomEvent.DataReceived, payload, participant, undefined, topic); } on(event: string, handler: Handler): this { const handlers = this.handlers.get(event) ?? new Set(); handlers.add(handler); this.handlers.set(event, handlers); return this; } off(event: string, handler: Handler): this { this.handlers.get(event)?.delete(handler); return this; } removeAllListeners(): this { this.handlers.clear(); return this; } connect(url: string, token: string): Promise { this.connectCalls.push({ url, token }); return Promise.resolve(); } disconnect(): Promise { this.disconnectCalls += 1; this.emit(RoomEvent.Disconnected); return Promise.resolve(); } startAudio(): Promise { this.startAudioCalls += 1; return Promise.resolve(); } switchActiveDevice(kind: string, deviceId: string): Promise { this.deviceSwitches.push({ kind, deviceId }); return Promise.resolve(true); } emit(event: string, ...args: unknown[]): void { for (const handler of [...(this.handlers.get(event) ?? [])]) { (handler as (...values: unknown[]) => void)(...args); } } /** Подключает удалённого участника: как `ParticipantConnected` из SDK. */ addRemote(userId: string, name: string, options: RemoteOptions = {}): FakeParticipant { const participant = new FakeParticipant(this, userId, name); participant.connectionQuality = options.quality ?? ConnectionQuality.Excellent; participant.setPublication(Track.Source.Microphone, options.muted ?? false); if (options.camera === true) { participant.setPublication(Track.Source.Camera); } if (options.screen === true) { participant.setPublication(Track.Source.ScreenShare); } this.remoteParticipants.set(userId, participant); this.emit(RoomEvent.ParticipantConnected, participant); this.emit(RoomEvent.TrackSubscribed); return participant; } removeRemote(userId: string): void { const participant = this.remoteParticipants.get(userId); if (participant === undefined) { return; } this.remoteParticipants.delete(userId); this.emit(RoomEvent.ParticipantDisconnected, participant); } /** Кто-то заговорил: `ActiveSpeakersChanged` из SDK. */ activeSpeakers(userIds: string[]): void { const speakerIds = new Set(userIds); this.localParticipant.setSpeaking(speakerIds.has(this.localParticipant.identity)); for (const participant of this.remoteParticipants.values()) { participant.setSpeaking(speakerIds.has(participant.identity)); } this.emit(RoomEvent.ActiveSpeakersChanged, []); } setQuality(userId: string, quality: string): void { const participant = this.remoteParticipants.get(userId); if (participant !== undefined) { participant.connectionQuality = quality; this.emit(RoomEvent.ConnectionQualityChanged, participant); } } }