2b9bf55f16
В шапке беседы — меню звонка: аудио, видео, «Присоединиться» к идущему и история. Когда голос на инстансе выключен, меню нет вовсе: обещать звонок, который не поднимется, нельзя. Входящий звонок показывается карточкой поверх любого экрана с принять и отклонить и рингтоном; идущий звонок — маркером в списке бесед и в шапке, а в области переписки вместо ленты открывается панель с плитками участников и кнопками микрофона, звука, камеры, шаринга и выхода. История звонков — кто звонил, когда, сколько длился разговор и кто не ответил. Тесты (vitest): начало звонка из шапки с подключением к комнате LiveKit, входящий звонок и его принятие, отклонение, маркер «идёт звонок» по событию и его снятие после завершения, флаги микрофона, камеры и экрана, история с пропущенными и длительностью, выключенный голос и маркер после перезагрузки (active_call в READY).
128 lines
3.5 KiB
TypeScript
128 lines
3.5 KiB
TypeScript
import { vi } from 'vitest';
|
|
|
|
/**
|
|
* Подставной `Audio` для тестов саундборда: в jsdom `play()` не реализован,
|
|
* поэтому звук подменяется целиком. Экземпляры собираются в массив — по ним
|
|
* проверяют, что звук скачали, проиграли или остановили.
|
|
*/
|
|
|
|
export class FakeAudio {
|
|
src: string;
|
|
volume = 1;
|
|
currentTime = 0;
|
|
paused = true;
|
|
playCalls = 0;
|
|
pauseCalls = 0;
|
|
/** Считаем и нативные, и «событийные» подписки: `ended` глушит звук. */
|
|
readonly listeners = new Map<string, Set<() => void>>();
|
|
|
|
constructor(src = '') {
|
|
this.src = src;
|
|
}
|
|
|
|
addEventListener(type: string, handler: () => void): void {
|
|
const set = this.listeners.get(type) ?? new Set<() => void>();
|
|
set.add(handler);
|
|
this.listeners.set(type, set);
|
|
}
|
|
|
|
removeEventListener(type: string, handler: () => void): void {
|
|
this.listeners.get(type)?.delete(handler);
|
|
}
|
|
|
|
play(): Promise<void> {
|
|
this.playCalls += 1;
|
|
this.paused = false;
|
|
return Promise.resolve();
|
|
}
|
|
|
|
pause(): void {
|
|
this.pauseCalls += 1;
|
|
this.paused = true;
|
|
}
|
|
|
|
/** Сообщить о конце звука: движок снимает элемент с учёта. */
|
|
emit(type: string): void {
|
|
for (const handler of [...(this.listeners.get(type) ?? [])]) {
|
|
handler();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Ставит подставной `Audio` и возвращает список созданных элементов. */
|
|
export function installFakeAudio(): FakeAudio[] {
|
|
const instances: FakeAudio[] = [];
|
|
class StubAudio extends FakeAudio {
|
|
constructor(src = '') {
|
|
super(src);
|
|
instances.push(this);
|
|
}
|
|
}
|
|
vi.stubGlobal('Audio', StubAudio);
|
|
return instances;
|
|
}
|
|
|
|
/**
|
|
* Подставной `AudioContext` для рингтона: jsdom не умеет Web Audio, поэтому
|
|
* осцилляторы записываются, а не звучат. По ним видно, что сигнал запустили,
|
|
* сколько раз и на каких частотах.
|
|
*/
|
|
export class FakeOscillator {
|
|
type = 'sine';
|
|
frequency = { value: 0 };
|
|
startCalls = 0;
|
|
stopCalls = 0;
|
|
|
|
connect(): void {
|
|
return undefined;
|
|
}
|
|
|
|
start(): void {
|
|
this.startCalls += 1;
|
|
}
|
|
|
|
stop(): void {
|
|
this.stopCalls += 1;
|
|
}
|
|
}
|
|
|
|
export class FakeAudioContext {
|
|
currentTime = 0;
|
|
destination = {};
|
|
readonly oscillators: FakeOscillator[] = [];
|
|
|
|
createOscillator(): FakeOscillator {
|
|
const oscillator = new FakeOscillator();
|
|
this.oscillators.push(oscillator);
|
|
return oscillator;
|
|
}
|
|
|
|
createGain(): { gain: Record<string, () => void>; connect: () => void } {
|
|
return {
|
|
gain: { setValueAtTime: () => undefined, linearRampToValueAtTime: () => undefined },
|
|
connect: () => undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Ставит подставной `AudioContext` и возвращает последний созданный контекст. */
|
|
export function installFakeAudioContext(): { current: () => FakeAudioContext } {
|
|
const created: FakeAudioContext[] = [];
|
|
class StubAudioContext extends FakeAudioContext {
|
|
constructor() {
|
|
super();
|
|
created.push(this);
|
|
}
|
|
}
|
|
vi.stubGlobal('AudioContext', StubAudioContext);
|
|
return {
|
|
current: () => {
|
|
const last = created.at(-1);
|
|
if (last === undefined) {
|
|
throw new Error('AudioContext ещё не создавался');
|
|
}
|
|
return last;
|
|
},
|
|
};
|
|
}
|