Files
grendervill 2b9bf55f16 feat(web): интерфейс звонков в беседах (Фаза 7)
В шапке беседы — меню звонка: аудио, видео, «Присоединиться» к идущему и
история. Когда голос на инстансе выключен, меню нет вовсе: обещать звонок,
который не поднимется, нельзя.

Входящий звонок показывается карточкой поверх любого экрана с принять и
отклонить и рингтоном; идущий звонок — маркером в списке бесед и в шапке, а
в области переписки вместо ленты открывается панель с плитками участников и
кнопками микрофона, звука, камеры, шаринга и выхода. История звонков — кто
звонил, когда, сколько длился разговор и кто не ответил.

Тесты (vitest): начало звонка из шапки с подключением к комнате LiveKit,
входящий звонок и его принятие, отклонение, маркер «идёт звонок» по событию
и его снятие после завершения, флаги микрофона, камеры и экрана, история с
пропущенными и длительностью, выключенный голос и маркер после
перезагрузки (active_call в READY).
2026-09-26 17:12:56 +03:00

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;
},
};
}