288 lines
8.9 KiB
TypeScript
288 lines
8.9 KiB
TypeScript
|
|
/**
|
||
|
|
* Подставной `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',
|
||
|
|
} as const;
|
||
|
|
|
||
|
|
export const ConnectionQuality = {
|
||
|
|
Excellent: 'excellent',
|
||
|
|
Good: 'good',
|
||
|
|
Poor: 'poor',
|
||
|
|
Lost: 'lost',
|
||
|
|
Unknown: 'unknown',
|
||
|
|
} 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[] = [];
|
||
|
|
|
||
|
|
constructor(kind: 'audio' | 'video') {
|
||
|
|
this.kind = kind;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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 }[] = [];
|
||
|
|
private readonly publications = new Map<string, FakePublication>();
|
||
|
|
|
||
|
|
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,
|
||
|
|
new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video'),
|
||
|
|
muted,
|
||
|
|
);
|
||
|
|
this.publications.set(source, publication);
|
||
|
|
return publication;
|
||
|
|
}
|
||
|
|
|
||
|
|
removePublication(source: string): void {
|
||
|
|
this.publications.delete(source);
|
||
|
|
this.room.emit(RoomEvent.TrackUnsubscribed);
|
||
|
|
}
|
||
|
|
|
||
|
|
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 ?? new FakeTrack(source === Track.Source.Microphone ? 'audio' : 'video');
|
||
|
|
this.publications.set(source, new FakePublication(source, track, !enabled));
|
||
|
|
this.room.emit(RoomEvent.LocalTrackPublished);
|
||
|
|
}
|
||
|
|
|
||
|
|
setMicrophoneEnabled(enabled: boolean): Promise<void> {
|
||
|
|
this.setLocal(Track.Source.Microphone, enabled, 'setMicrophoneEnabled');
|
||
|
|
return Promise.resolve();
|
||
|
|
}
|
||
|
|
|
||
|
|
setCameraEnabled(enabled: boolean): Promise<void> {
|
||
|
|
this.setLocal(Track.Source.Camera, enabled, 'setCameraEnabled');
|
||
|
|
return Promise.resolve();
|
||
|
|
}
|
||
|
|
|
||
|
|
setScreenShareEnabled(enabled: boolean): Promise<void> {
|
||
|
|
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<string, FakeParticipant>();
|
||
|
|
readonly connectCalls: { url: string; token: string }[] = [];
|
||
|
|
readonly deviceSwitches: { kind: string; deviceId: string }[] = [];
|
||
|
|
disconnectCalls = 0;
|
||
|
|
startAudioCalls = 0;
|
||
|
|
private readonly handlers = new Map<string, Set<Handler>>();
|
||
|
|
|
||
|
|
constructor(readonly options?: unknown) {
|
||
|
|
FakeRoom.instances.push(this);
|
||
|
|
this.localParticipant = new FakeParticipant(this, 'user-1', 'Alice', true);
|
||
|
|
}
|
||
|
|
|
||
|
|
on(event: string, handler: Handler): this {
|
||
|
|
const handlers = this.handlers.get(event) ?? new Set<Handler>();
|
||
|
|
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<void> {
|
||
|
|
this.connectCalls.push({ url, token });
|
||
|
|
return Promise.resolve();
|
||
|
|
}
|
||
|
|
|
||
|
|
disconnect(): Promise<void> {
|
||
|
|
this.disconnectCalls += 1;
|
||
|
|
this.emit(RoomEvent.Disconnected);
|
||
|
|
return Promise.resolve();
|
||
|
|
}
|
||
|
|
|
||
|
|
startAudio(): Promise<void> {
|
||
|
|
this.startAudioCalls += 1;
|
||
|
|
return Promise.resolve();
|
||
|
|
}
|
||
|
|
|
||
|
|
switchActiveDevice(kind: string, deviceId: string): Promise<boolean> {
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|