Files

343 lines
12 KiB
TypeScript
Raw Permalink Normal View History

/**
* Подставной `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<Map<string, unknown>> {
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<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, 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<void> {
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<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();
}
/** Демонстрация экрана: параметры захвата (разрешение и fps) запоминаем. */
setScreenShareEnabled(enabled: boolean, options?: unknown): Promise<void> {
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<string, FakeParticipant>();
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<string, Set<Handler>>();
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<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);
}
}
}