mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-01 21:25:18 +00:00
Start to build out tests
This commit is contained in:
@@ -115,12 +115,12 @@ function useMediaDevice(
|
|||||||
}, [available, preferredId, select, alwaysDefault]);
|
}, [available, preferredId, select, alwaysDefault]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const deviceStub: MediaDevice = {
|
export const deviceStub: MediaDevice = {
|
||||||
available: [],
|
available: [],
|
||||||
selectedId: undefined,
|
selectedId: undefined,
|
||||||
select: () => {},
|
select: () => {},
|
||||||
};
|
};
|
||||||
const devicesStub: MediaDevices = {
|
export const devicesStub: MediaDevices = {
|
||||||
audioInput: deviceStub,
|
audioInput: deviceStub,
|
||||||
audioOutput: deviceStub,
|
audioOutput: deviceStub,
|
||||||
videoInput: deviceStub,
|
videoInput: deviceStub,
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { beforeEach, expect, test } from "vitest";
|
import { beforeEach, expect, test, vitest } from "vitest";
|
||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||||
import { ConnectionState, RemoteParticipant, Room } from "livekit-client";
|
import { ConnectionState, RemoteParticipant, Room } from "livekit-client";
|
||||||
import { of } from "rxjs";
|
import { of } from "rxjs";
|
||||||
import { afterEach } from "node:test";
|
import { act, ReactNode } from "react";
|
||||||
import { act } from "react";
|
|
||||||
|
|
||||||
import { soundEffectVolumeSetting } from "../settings/settings";
|
import { soundEffectVolumeSetting } from "../settings/settings";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
mockLocalParticipant,
|
mockLocalParticipant,
|
||||||
mockMatrixRoom,
|
mockMatrixRoom,
|
||||||
mockMatrixRoomMember,
|
mockMatrixRoomMember,
|
||||||
mockMediaPlay,
|
|
||||||
mockRemoteParticipant,
|
mockRemoteParticipant,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
@@ -29,6 +27,17 @@ import {
|
|||||||
CallEventAudioRenderer,
|
CallEventAudioRenderer,
|
||||||
MAX_PARTICIPANT_COUNT_FOR_SOUND,
|
MAX_PARTICIPANT_COUNT_FOR_SOUND,
|
||||||
} from "./CallEventAudioRenderer";
|
} from "./CallEventAudioRenderer";
|
||||||
|
import {
|
||||||
|
prefetchSounds,
|
||||||
|
// We're using this from our mock, but it doesn't exist in the actual module.
|
||||||
|
//@ts-ignore
|
||||||
|
playSound,
|
||||||
|
} from "../useAudioContext";
|
||||||
|
import {
|
||||||
|
MockRoom,
|
||||||
|
MockRTCSession,
|
||||||
|
TestReactionsWrapper,
|
||||||
|
} from "../utils/testReactions";
|
||||||
|
|
||||||
const alice = mockMatrixRoomMember({ userId: "@alice:example.org" });
|
const alice = mockMatrixRoomMember({ userId: "@alice:example.org" });
|
||||||
const bob = mockMatrixRoomMember({ userId: "@bob:example.org" });
|
const bob = mockMatrixRoomMember({ userId: "@bob:example.org" });
|
||||||
@@ -37,20 +46,39 @@ const bobId = `${bob.userId}:BBBB`;
|
|||||||
const localParticipant = mockLocalParticipant({ identity: "" });
|
const localParticipant = mockLocalParticipant({ identity: "" });
|
||||||
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
|
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
|
||||||
const bobParticipant = mockRemoteParticipant({ identity: bobId });
|
const bobParticipant = mockRemoteParticipant({ identity: bobId });
|
||||||
|
|
||||||
const originalPlayFn = window.HTMLMediaElement.prototype.play;
|
|
||||||
|
|
||||||
const enterSound = "http://localhost:3000/src/sound/join_call.ogg";
|
|
||||||
const leaveSound = "http://localhost:3000/src/sound/left_call.ogg";
|
const leaveSound = "http://localhost:3000/src/sound/left_call.ogg";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
|
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
vitest.mock("../useAudioContext", async () => {
|
||||||
window.HTMLMediaElement.prototype.play = originalPlayFn;
|
const playSound = vitest.fn();
|
||||||
|
return {
|
||||||
|
prefetchSounds: vitest.fn().mockReturnValueOnce({
|
||||||
|
sound: new ArrayBuffer(0),
|
||||||
|
}),
|
||||||
|
playSound,
|
||||||
|
useAudioContext: () => ({
|
||||||
|
playSound,
|
||||||
|
}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function TestComponent({
|
||||||
|
room,
|
||||||
|
vm,
|
||||||
|
}: {
|
||||||
|
room: MockRoom;
|
||||||
|
vm: CallViewModel;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<TestReactionsWrapper rtcSession={new MockRTCSession(room, {})}>
|
||||||
|
<CallEventAudioRenderer vm={vm} />
|
||||||
|
</TestReactionsWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* We don't want to play a sound when loading the call state
|
* We don't want to play a sound when loading the call state
|
||||||
* because typically this occurs in two stages. We first join
|
* because typically this occurs in two stages. We first join
|
||||||
@@ -59,21 +87,15 @@ afterEach(() => {
|
|||||||
* a noise every time.
|
* a noise every time.
|
||||||
*/
|
*/
|
||||||
test("does NOT play a sound when entering a call", () => {
|
test("does NOT play a sound when entering a call", () => {
|
||||||
const audioIsPlaying: string[] = mockMediaPlay();
|
|
||||||
const members = new Map([alice, bob].map((p) => [p.userId, p]));
|
const members = new Map([alice, bob].map((p) => [p.userId, p]));
|
||||||
const remoteParticipants = of([aliceParticipant]);
|
const remoteParticipants = of([aliceParticipant]);
|
||||||
const liveKitRoom = mockLivekitRoom(
|
const liveKitRoom = mockLivekitRoom(
|
||||||
{ localParticipant },
|
{ localParticipant },
|
||||||
{ remoteParticipants },
|
{ remoteParticipants },
|
||||||
);
|
);
|
||||||
|
const room = new MockRoom(alice.userId);
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
mockMatrixRoom({
|
room as any,
|
||||||
client: {
|
|
||||||
getUserId: () => "@carol:example.org",
|
|
||||||
} as Partial<MatrixClient> as MatrixClient,
|
|
||||||
getMember: (userId) => members.get(userId) ?? null,
|
|
||||||
}),
|
|
||||||
liveKitRoom,
|
liveKitRoom,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
@@ -81,13 +103,12 @@ test("does NOT play a sound when entering a call", () => {
|
|||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<TestComponent room={room} vm={vm} />);
|
||||||
expect(audioIsPlaying).toHaveLength(0);
|
expect(playSound).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays no sound when muted", () => {
|
test("plays no sound when muted", () => {
|
||||||
soundEffectVolumeSetting.setValue(0);
|
soundEffectVolumeSetting.setValue(0);
|
||||||
const audioIsPlaying: string[] = mockMediaPlay();
|
|
||||||
const members = new Map([alice, bob].map((p) => [p.userId, p]));
|
const members = new Map([alice, bob].map((p) => [p.userId, p]));
|
||||||
const remoteParticipants = of([aliceParticipant, bobParticipant]);
|
const remoteParticipants = of([aliceParticipant, bobParticipant]);
|
||||||
const liveKitRoom = mockLivekitRoom(
|
const liveKitRoom = mockLivekitRoom(
|
||||||
@@ -95,13 +116,9 @@ test("plays no sound when muted", () => {
|
|||||||
{ remoteParticipants },
|
{ remoteParticipants },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const room = new MockRoom(alice.userId);
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
mockMatrixRoom({
|
room as any,
|
||||||
client: {
|
|
||||||
getUserId: () => "@carol:example.org",
|
|
||||||
} as Partial<MatrixClient> as MatrixClient,
|
|
||||||
getMember: (userId) => members.get(userId) ?? null,
|
|
||||||
}),
|
|
||||||
liveKitRoom,
|
liveKitRoom,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
@@ -109,14 +126,12 @@ test("plays no sound when muted", () => {
|
|||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
);
|
);
|
||||||
|
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<TestComponent room={room} vm={vm} />);
|
||||||
// Play a sound when joining a call.
|
// Play a sound when joining a call.
|
||||||
expect(audioIsPlaying).toHaveLength(0);
|
expect(playSound).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays a sound when a user joins", () => {
|
test("plays a sound when a user joins", () => {
|
||||||
const audioIsPlaying: string[] = mockMediaPlay();
|
|
||||||
const members = new Map([alice].map((p) => [p.userId, p]));
|
|
||||||
const remoteParticipants = new Map(
|
const remoteParticipants = new Map(
|
||||||
[aliceParticipant].map((p) => [p.identity, p]),
|
[aliceParticipant].map((p) => [p.identity, p]),
|
||||||
);
|
);
|
||||||
@@ -125,34 +140,25 @@ test("plays a sound when a user joins", () => {
|
|||||||
remoteParticipants,
|
remoteParticipants,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const room = new MockRoom(alice.userId);
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
mockMatrixRoom({
|
room as any,
|
||||||
client: {
|
|
||||||
getUserId: () => "@carol:example.org",
|
|
||||||
} as Partial<MatrixClient> as MatrixClient,
|
|
||||||
getMember: (userId) => members.get(userId) ?? null,
|
|
||||||
}),
|
|
||||||
liveKitRoom as unknown as Room,
|
liveKitRoom as unknown as Room,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
);
|
);
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<TestComponent room={room} vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
liveKitRoom.addParticipant(bobParticipant);
|
liveKitRoom.addParticipant(bobParticipant);
|
||||||
});
|
});
|
||||||
// Play a sound when joining a call.
|
// Play a sound when joining a call.
|
||||||
expect(audioIsPlaying).toEqual([
|
expect(playSound).toBeCalledWith("join");
|
||||||
// Bob leaves
|
|
||||||
enterSound,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays a sound when a user leaves", () => {
|
test("plays a sound when a user leaves", () => {
|
||||||
const audioIsPlaying: string[] = mockMediaPlay();
|
|
||||||
const members = new Map([alice].map((p) => [p.userId, p]));
|
|
||||||
const remoteParticipants = new Map(
|
const remoteParticipants = new Map(
|
||||||
[aliceParticipant].map((p) => [p.identity, p]),
|
[aliceParticipant].map((p) => [p.identity, p]),
|
||||||
);
|
);
|
||||||
@@ -160,34 +166,25 @@ test("plays a sound when a user leaves", () => {
|
|||||||
localParticipant,
|
localParticipant,
|
||||||
remoteParticipants,
|
remoteParticipants,
|
||||||
});
|
});
|
||||||
|
const room = new MockRoom(alice.userId);
|
||||||
|
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
mockMatrixRoom({
|
room as any,
|
||||||
client: {
|
|
||||||
getUserId: () => "@carol:example.org",
|
|
||||||
} as Partial<MatrixClient> as MatrixClient,
|
|
||||||
getMember: (userId) => members.get(userId) ?? null,
|
|
||||||
}),
|
|
||||||
liveKitRoom as unknown as Room,
|
liveKitRoom as unknown as Room,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
);
|
);
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<TestComponent room={room} vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
liveKitRoom.removeParticipant(aliceParticipant);
|
liveKitRoom.removeParticipant(aliceParticipant);
|
||||||
});
|
});
|
||||||
expect(audioIsPlaying).toEqual([
|
expect(playSound).toBeCalledWith("leave");
|
||||||
// Alice leaves
|
|
||||||
leaveSound,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plays no sound when the participant list is more than the maximum size", () => {
|
test("plays no sound when the participant list is more than the maximum size", () => {
|
||||||
const audioIsPlaying: string[] = mockMediaPlay();
|
|
||||||
const members = new Map([alice].map((p) => [p.userId, p]));
|
|
||||||
const remoteParticipants = new Map<string, RemoteParticipant>([
|
const remoteParticipants = new Map<string, RemoteParticipant>([
|
||||||
[aliceParticipant.identity, aliceParticipant],
|
[aliceParticipant.identity, aliceParticipant],
|
||||||
...Array.from({ length: MAX_PARTICIPANT_COUNT_FOR_SOUND - 1 }).map<
|
...Array.from({ length: MAX_PARTICIPANT_COUNT_FOR_SOUND - 1 }).map<
|
||||||
@@ -201,25 +198,19 @@ test("plays no sound when the participant list is more than the maximum size", (
|
|||||||
localParticipant,
|
localParticipant,
|
||||||
remoteParticipants,
|
remoteParticipants,
|
||||||
});
|
});
|
||||||
|
const room = new MockRoom(alice.userId);
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
mockMatrixRoom({
|
room as any,
|
||||||
client: {
|
|
||||||
getUserId: () => "@carol:example.org",
|
|
||||||
} as Partial<MatrixClient> as MatrixClient,
|
|
||||||
getMember: (userId) => members.get(userId) ?? null,
|
|
||||||
}),
|
|
||||||
liveKitRoom as unknown as Room,
|
liveKitRoom as unknown as Room,
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
);
|
);
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<TestComponent room={room} vm={vm} />);
|
||||||
expect(audioIsPlaying).toEqual([]);
|
|
||||||
// When the count drops
|
// When the count drops
|
||||||
act(() => {
|
act(() => {
|
||||||
liveKitRoom.removeParticipant(aliceParticipant);
|
liveKitRoom.removeParticipant(aliceParticipant);
|
||||||
});
|
});
|
||||||
expect(audioIsPlaying).toEqual([leaveSound]);
|
expect(playSound).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactNode, useDeferredValue, useEffect, useMemo } from "react";
|
import { ReactNode, useDeferredValue, useEffect, useMemo } from "react";
|
||||||
import { debounce, filter, interval, throttle } from "rxjs";
|
import { debounce, filter, interval, tap, throttle } from "rxjs";
|
||||||
import { CallViewModel } from "../state/CallViewModel";
|
import { CallViewModel } from "../state/CallViewModel";
|
||||||
import joinCallSoundMp3 from "../sound/join_call.mp3";
|
import joinCallSoundMp3 from "../sound/join_call.mp3";
|
||||||
import joinCallSoundOgg from "../sound/join_call.ogg";
|
import joinCallSoundOgg from "../sound/join_call.ogg";
|
||||||
@@ -16,12 +16,12 @@ import handSoundOgg from "../sound/raise_hand.ogg?url";
|
|||||||
import handSoundMp3 from "../sound/raise_hand.mp3?url";
|
import handSoundMp3 from "../sound/raise_hand.mp3?url";
|
||||||
import { prefetchSounds, useAudioContext } from "../useAudioContext";
|
import { prefetchSounds, useAudioContext } from "../useAudioContext";
|
||||||
import { useReactions } from "../useReactions";
|
import { useReactions } from "../useReactions";
|
||||||
|
import { useLatest } from "../useLatest";
|
||||||
|
|
||||||
// Do not play any sounds if the participant count has exceeded this
|
// Do not play any sounds if the participant count has exceeded this
|
||||||
// number.
|
// number.
|
||||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||||
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
export const THROTTLE_SOUND_EFFECT_MS = 500;
|
||||||
export const DEBOUNCE_SOUND_EFFECT_MS = 150;
|
|
||||||
|
|
||||||
const Sounds = prefetchSounds({
|
const Sounds = prefetchSounds({
|
||||||
join: {
|
join: {
|
||||||
@@ -47,6 +47,7 @@ export function CallEventAudioRenderer({
|
|||||||
sounds: Sounds,
|
sounds: Sounds,
|
||||||
latencyHint: "interactive",
|
latencyHint: "interactive",
|
||||||
});
|
});
|
||||||
|
const audioEngineRef = useLatest(audioEngineCtx);
|
||||||
|
|
||||||
const { raisedHands } = useReactions();
|
const { raisedHands } = useReactions();
|
||||||
const raisedHandCount = useMemo(
|
const raisedHandCount = useMemo(
|
||||||
@@ -56,16 +57,12 @@ export function CallEventAudioRenderer({
|
|||||||
const previousRaisedHandCount = useDeferredValue(raisedHandCount);
|
const previousRaisedHandCount = useDeferredValue(raisedHandCount);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioEngineCtx && previousRaisedHandCount < raisedHandCount) {
|
if (audioEngineRef.current && previousRaisedHandCount < raisedHandCount) {
|
||||||
audioEngineCtx.playSound("raiseHand");
|
audioEngineRef.current.playSound("raiseHand");
|
||||||
}
|
}
|
||||||
}, [audioEngineCtx, previousRaisedHandCount, raisedHandCount]);
|
}, [audioEngineRef, previousRaisedHandCount, raisedHandCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!audioEngineCtx) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const joinSub = vm.memberChanges
|
const joinSub = vm.memberChanges
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(
|
filter(
|
||||||
@@ -73,11 +70,9 @@ export function CallEventAudioRenderer({
|
|||||||
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
|
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
|
||||||
),
|
),
|
||||||
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
||||||
debounce(() => interval(DEBOUNCE_SOUND_EFFECT_MS)),
|
|
||||||
)
|
)
|
||||||
.subscribe((prev) => {
|
.subscribe(() => {
|
||||||
console.log("Playing join sound for", ...prev.joined, "|", prev);
|
audioEngineRef.current?.playSound("join");
|
||||||
audioEngineCtx.playSound("join");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const leftSub = vm.memberChanges
|
const leftSub = vm.memberChanges
|
||||||
@@ -87,17 +82,16 @@ export function CallEventAudioRenderer({
|
|||||||
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
|
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
|
||||||
),
|
),
|
||||||
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
|
||||||
debounce(() => interval(DEBOUNCE_SOUND_EFFECT_MS)),
|
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
audioEngineCtx.playSound("left");
|
audioEngineRef.current?.playSound("left");
|
||||||
});
|
});
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
joinSub.unsubscribe();
|
joinSub.unsubscribe();
|
||||||
leftSub.unsubscribe();
|
leftSub.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [audioEngineCtx, vm]);
|
}, [audioEngineRef, vm]);
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { afterAll, expect, test } from "vitest";
|
import { afterAll, expect, test, vitest } from "vitest";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { act, ReactNode } from "react";
|
import { act, ReactNode } from "react";
|
||||||
|
|
||||||
@@ -16,12 +16,18 @@ import {
|
|||||||
TestReactionsWrapper,
|
TestReactionsWrapper,
|
||||||
} from "../utils/testReactions";
|
} from "../utils/testReactions";
|
||||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||||
import { GenericReaction, ReactionSet } from "../reactions";
|
|
||||||
import {
|
import {
|
||||||
playReactionsSound,
|
playReactionsSound,
|
||||||
soundEffectVolumeSetting,
|
soundEffectVolumeSetting,
|
||||||
} from "../settings/settings";
|
} from "../settings/settings";
|
||||||
import { mockMediaPlay } from "../utils/test";
|
import {
|
||||||
|
prefetchSounds,
|
||||||
|
// We're using this from our mock, but it doesn't exist in the actual module.
|
||||||
|
//@ts-ignore
|
||||||
|
playSound,
|
||||||
|
} from "../useAudioContext";
|
||||||
|
import { GenericReaction, ReactionSet } from "../reactions";
|
||||||
|
import { afterEach } from "node:test";
|
||||||
|
|
||||||
const memberUserIdAlice = "@alice:example.org";
|
const memberUserIdAlice = "@alice:example.org";
|
||||||
const memberUserIdBob = "@bob:example.org";
|
const memberUserIdBob = "@bob:example.org";
|
||||||
@@ -50,11 +56,27 @@ function TestComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const originalPlayFn = window.HTMLMediaElement.prototype.play;
|
vitest.mock("../useAudioContext", async () => {
|
||||||
afterAll(() => {
|
const playSound = vitest.fn();
|
||||||
|
return {
|
||||||
|
prefetchSounds: vitest.fn().mockReturnValueOnce({
|
||||||
|
sound: new ArrayBuffer(0),
|
||||||
|
}),
|
||||||
|
playSound,
|
||||||
|
useAudioContext: () => ({
|
||||||
|
playSound,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vitest.clearAllMocks();
|
||||||
playReactionsSound.setValue(playReactionsSound.defaultValue);
|
playReactionsSound.setValue(playReactionsSound.defaultValue);
|
||||||
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
|
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
|
||||||
window.HTMLMediaElement.prototype.play = originalPlayFn;
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vitest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preloads all audio elements", () => {
|
test("preloads all audio elements", () => {
|
||||||
@@ -63,25 +85,11 @@ test("preloads all audio elements", () => {
|
|||||||
new MockRoom(memberUserIdAlice),
|
new MockRoom(memberUserIdAlice),
|
||||||
membership,
|
membership,
|
||||||
);
|
);
|
||||||
const { container } = render(<TestComponent rtcSession={rtcSession} />);
|
render(<TestComponent rtcSession={rtcSession} />);
|
||||||
expect(container.getElementsByTagName("audio")).toHaveLength(
|
expect(prefetchSounds).toHaveBeenCalledOnce();
|
||||||
// All reactions plus the generic sound
|
|
||||||
ReactionSet.filter((r) => r.sound).length + 1,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("loads no audio elements when disabled in settings", () => {
|
|
||||||
playReactionsSound.setValue(false);
|
|
||||||
const rtcSession = new MockRTCSession(
|
|
||||||
new MockRoom(memberUserIdAlice),
|
|
||||||
membership,
|
|
||||||
);
|
|
||||||
const { container } = render(<TestComponent rtcSession={rtcSession} />);
|
|
||||||
expect(container.getElementsByTagName("audio")).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("will play an audio sound when there is a reaction", () => {
|
test("will play an audio sound when there is a reaction", () => {
|
||||||
const audioIsPlaying: string[] = mockMediaPlay();
|
|
||||||
playReactionsSound.setValue(true);
|
playReactionsSound.setValue(true);
|
||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
@@ -97,12 +105,10 @@ test("will play an audio sound when there is a reaction", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
||||||
});
|
});
|
||||||
expect(audioIsPlaying).toHaveLength(1);
|
expect(playSound).toHaveBeenCalledWith(chosenReaction.name);
|
||||||
expect(audioIsPlaying[0]).toContain(chosenReaction.sound?.ogg);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("will play the generic audio sound when there is soundless reaction", () => {
|
test("will play the generic audio sound when there is soundless reaction", () => {
|
||||||
const audioIsPlaying: string[] = mockMediaPlay();
|
|
||||||
playReactionsSound.setValue(true);
|
playReactionsSound.setValue(true);
|
||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
const rtcSession = new MockRTCSession(room, membership);
|
||||||
@@ -118,34 +124,10 @@ test("will play the generic audio sound when there is soundless reaction", () =>
|
|||||||
act(() => {
|
act(() => {
|
||||||
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
||||||
});
|
});
|
||||||
expect(audioIsPlaying).toHaveLength(1);
|
expect(playSound).toHaveBeenCalledWith(GenericReaction.name);
|
||||||
expect(audioIsPlaying[0]).toContain(GenericReaction.sound?.ogg);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("will play an audio sound with the correct volume", () => {
|
|
||||||
playReactionsSound.setValue(true);
|
|
||||||
soundEffectVolumeSetting.setValue(0.5);
|
|
||||||
const room = new MockRoom(memberUserIdAlice);
|
|
||||||
const rtcSession = new MockRTCSession(room, membership);
|
|
||||||
const { getByTestId } = render(<TestComponent rtcSession={rtcSession} />);
|
|
||||||
|
|
||||||
// Find the first reaction with a sound effect
|
|
||||||
const chosenReaction = ReactionSet.find((r) => !!r.sound);
|
|
||||||
if (!chosenReaction) {
|
|
||||||
throw Error(
|
|
||||||
"No reactions have sounds configured, this test cannot succeed",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
act(() => {
|
|
||||||
room.testSendReaction(memberEventAlice, chosenReaction, membership);
|
|
||||||
});
|
|
||||||
expect((getByTestId(chosenReaction.name) as HTMLAudioElement).volume).toEqual(
|
|
||||||
0.5,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("will play multiple audio sounds when there are multiple different reactions", () => {
|
test("will play multiple audio sounds when there are multiple different reactions", () => {
|
||||||
const audioIsPlaying: string[] = mockMediaPlay();
|
|
||||||
playReactionsSound.setValue(true);
|
playReactionsSound.setValue(true);
|
||||||
|
|
||||||
const room = new MockRoom(memberUserIdAlice);
|
const room = new MockRoom(memberUserIdAlice);
|
||||||
@@ -164,7 +146,6 @@ test("will play multiple audio sounds when there are multiple different reaction
|
|||||||
room.testSendReaction(memberEventBob, reaction2, membership);
|
room.testSendReaction(memberEventBob, reaction2, membership);
|
||||||
room.testSendReaction(memberEventCharlie, reaction1, membership);
|
room.testSendReaction(memberEventCharlie, reaction1, membership);
|
||||||
});
|
});
|
||||||
expect(audioIsPlaying).toHaveLength(2);
|
expect(playSound).toHaveBeenCalledWith(reaction1.name);
|
||||||
expect(audioIsPlaying[0]).toContain(reaction1.sound?.ogg);
|
expect(playSound).toHaveBeenCalledWith(reaction2.name);
|
||||||
expect(audioIsPlaying[1]).toContain(reaction2.sound?.ogg);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useReactions } from "../useReactions";
|
|||||||
import { playReactionsSound, useSetting } from "../settings/settings";
|
import { playReactionsSound, useSetting } from "../settings/settings";
|
||||||
import { ReactionSet } from "../reactions";
|
import { ReactionSet } from "../reactions";
|
||||||
import { prefetchSounds, useAudioContext } from "../useAudioContext";
|
import { prefetchSounds, useAudioContext } from "../useAudioContext";
|
||||||
|
import { useLatest } from "../useLatest";
|
||||||
|
|
||||||
const SoundMap = Object.fromEntries(
|
const SoundMap = Object.fromEntries(
|
||||||
ReactionSet.filter((v) => v.sound !== undefined).map((v) => [
|
ReactionSet.filter((v) => v.sound !== undefined).map((v) => [
|
||||||
@@ -28,14 +29,11 @@ export function ReactionsAudioRenderer(): ReactNode {
|
|||||||
sounds: Sounds,
|
sounds: Sounds,
|
||||||
latencyHint: "interactive",
|
latencyHint: "interactive",
|
||||||
});
|
});
|
||||||
|
const audioEngineRef = useLatest(audioEngineCtx);
|
||||||
const oldReactions = useDeferredValue(reactions);
|
const oldReactions = useDeferredValue(reactions);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!audioEngineCtx) {
|
if (!shouldPlay || !audioEngineRef.current) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldPlay) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const oldReactionSet = new Set(
|
const oldReactionSet = new Set(
|
||||||
@@ -48,13 +46,14 @@ export function ReactionsAudioRenderer(): ReactNode {
|
|||||||
// Don't replay old reactions
|
// Don't replay old reactions
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log("playing sound", reactionName);
|
||||||
if (SoundMap[reactionName]) {
|
if (SoundMap[reactionName]) {
|
||||||
audioEngineCtx.playSound(reactionName);
|
audioEngineRef.current.playSound(reactionName);
|
||||||
} else {
|
} else {
|
||||||
// Fallback sounds.
|
// Fallback sounds.
|
||||||
audioEngineCtx.playSound("generic");
|
audioEngineRef.current.playSound("generic");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [shouldPlay, oldReactions, reactions]);
|
}, [audioEngineRef, shouldPlay, oldReactions, reactions]);
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|||||||
126
src/useAudioContext.test.tsx
Normal file
126
src/useAudioContext.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { expect, test, vitest } from "vitest";
|
||||||
|
import { useAudioContext } from "./useAudioContext";
|
||||||
|
import { FC } from "react";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext";
|
||||||
|
import { afterEach } from "node:test";
|
||||||
|
import { soundEffectVolumeSetting } from "./settings/settings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test explanation.
|
||||||
|
* This test suite checks that the useReactions hook appropriately reacts
|
||||||
|
* to new reactions, redactions and membership changesin the room. There is
|
||||||
|
* a large amount of test structure used to construct a mock environment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TestComponent: FC = () => {
|
||||||
|
const audioCtx = useAudioContext({
|
||||||
|
sounds: Promise.resolve({
|
||||||
|
aSound: new ArrayBuffer(32),
|
||||||
|
}),
|
||||||
|
latencyHint: "balanced",
|
||||||
|
});
|
||||||
|
if (!audioCtx) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button role="button" onClick={() => audioCtx.playSound("aSound")}>
|
||||||
|
Valid sound
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="button"
|
||||||
|
onClick={() => audioCtx.playSound("not-valid" as any)}
|
||||||
|
>
|
||||||
|
Invalid sound
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
class MockAudioContext {
|
||||||
|
static testContext: MockAudioContext;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
MockAudioContext.testContext = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public gain = vitest.mocked(
|
||||||
|
{
|
||||||
|
connect: () => {},
|
||||||
|
gain: {
|
||||||
|
setValueAtTime: vitest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
public setSinkId = vitest.fn().mockResolvedValue(undefined);
|
||||||
|
public decodeAudioData = vitest.fn().mockReturnValue(1);
|
||||||
|
public createBufferSource = vitest.fn().mockReturnValue(
|
||||||
|
vitest.mocked({
|
||||||
|
connect: (v: unknown) => v,
|
||||||
|
start: () => {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
public createGain = vitest.fn().mockReturnValue(this.gain);
|
||||||
|
public close = vitest.fn().mockResolvedValue(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vitest.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can play a single sound", async () => {
|
||||||
|
vitest.stubGlobal("AudioContext", MockAudioContext);
|
||||||
|
const { findByText } = render(<TestComponent />);
|
||||||
|
(await findByText("Valid sound")).click();
|
||||||
|
expect(
|
||||||
|
MockAudioContext.testContext.createBufferSource,
|
||||||
|
).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
test("will ignore sounds that are not registered", async () => {
|
||||||
|
vitest.stubGlobal("AudioContext", MockAudioContext);
|
||||||
|
const { findByText } = render(<TestComponent />);
|
||||||
|
(await findByText("Invalid sound")).click();
|
||||||
|
expect(
|
||||||
|
MockAudioContext.testContext.createBufferSource,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("will use the correct device", async () => {
|
||||||
|
vitest.stubGlobal("AudioContext", MockAudioContext);
|
||||||
|
render(
|
||||||
|
<MediaDevicesContext.Provider
|
||||||
|
value={{
|
||||||
|
audioInput: deviceStub,
|
||||||
|
audioOutput: {
|
||||||
|
selectedId: "chosen-device",
|
||||||
|
available: [],
|
||||||
|
select: () => {},
|
||||||
|
},
|
||||||
|
videoInput: deviceStub,
|
||||||
|
startUsingDeviceNames: () => {},
|
||||||
|
stopUsingDeviceNames: () => {},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TestComponent />
|
||||||
|
</MediaDevicesContext.Provider>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
MockAudioContext.testContext.createBufferSource,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
expect(MockAudioContext.testContext.setSinkId).toHaveBeenCalledWith(
|
||||||
|
"chosen-device",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("will use the correct volume", async () => {
|
||||||
|
vitest.stubGlobal("AudioContext", MockAudioContext);
|
||||||
|
soundEffectVolumeSetting.setValue(0.33);
|
||||||
|
const { findByText } = render(<TestComponent />);
|
||||||
|
(await findByText("Valid sound")).click();
|
||||||
|
expect(
|
||||||
|
MockAudioContext.testContext.gain.gain.setValueAtTime,
|
||||||
|
).toHaveBeenCalledWith(0.33, 0);
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useSetting,
|
useSetting,
|
||||||
} from "./settings/settings";
|
} from "./settings/settings";
|
||||||
import { useMediaDevices } from "./livekit/MediaDevicesContext";
|
import { useMediaDevices } from "./livekit/MediaDevicesContext";
|
||||||
|
import { useInitial } from "./useInitial";
|
||||||
|
|
||||||
type SoundDefinition = { mp3?: string; ogg: string };
|
type SoundDefinition = { mp3?: string; ogg: string };
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ function getPreferredAudioFormat() {
|
|||||||
return "mp3";
|
return "mp3";
|
||||||
}
|
}
|
||||||
|
|
||||||
type PrefetchedSounds<S extends string> = Promise<Record<string, ArrayBuffer>>;
|
type PrefetchedSounds<S extends string> = Promise<Record<S, ArrayBuffer>>;
|
||||||
|
|
||||||
// We prefer to load these sounds ahead of time, so there
|
// We prefer to load these sounds ahead of time, so there
|
||||||
// is no delay on call join.
|
// is no delay on call join.
|
||||||
@@ -82,6 +83,12 @@ interface UseAudioContext<S> {
|
|||||||
playSound(soundName: S): void;
|
playSound(soundName: S): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an audio context which can be used to play
|
||||||
|
* a set of preloaded sounds.
|
||||||
|
* @param props
|
||||||
|
* @returns Either an instance that can be used to play sounds, or null if not ready.
|
||||||
|
*/
|
||||||
export function useAudioContext<S extends string>(
|
export function useAudioContext<S extends string>(
|
||||||
props: Props<S>,
|
props: Props<S>,
|
||||||
): UseAudioContext<S> | null {
|
): UseAudioContext<S> | null {
|
||||||
@@ -89,6 +96,7 @@ export function useAudioContext<S extends string>(
|
|||||||
const devices = useMediaDevices();
|
const devices = useMediaDevices();
|
||||||
const [audioContext, setAudioContext] = useState<AudioContext>();
|
const [audioContext, setAudioContext] = useState<AudioContext>();
|
||||||
const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>();
|
const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>();
|
||||||
|
const soundCache = useInitial(() => props.sounds);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ctx = new AudioContext({
|
const ctx = new AudioContext({
|
||||||
@@ -101,13 +109,15 @@ export function useAudioContext<S extends string>(
|
|||||||
(async () => {
|
(async () => {
|
||||||
const buffers: Record<string, AudioBuffer> = {};
|
const buffers: Record<string, AudioBuffer> = {};
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
for (const [name, buffer] of Object.entries(await props.sounds)) {
|
for (const [name, buffer] of Object.entries(await soundCache)) {
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
const audioBuffer = await ctx.decodeAudioData(buffer.slice(0));
|
// Type quirk, this is *definitely* a ArrayBuffer.
|
||||||
|
const audioBuffer = await ctx.decodeAudioData(
|
||||||
|
(buffer as ArrayBuffer).slice(0),
|
||||||
|
);
|
||||||
buffers[name] = audioBuffer;
|
buffers[name] = audioBuffer;
|
||||||
// Store as we go.
|
|
||||||
setAudioBuffers(buffers as Record<S, AudioBuffer>);
|
|
||||||
}
|
}
|
||||||
|
setAudioBuffers(buffers as Record<S, AudioBuffer>);
|
||||||
})().catch((ex) => {
|
})().catch((ex) => {
|
||||||
logger.debug("Failed to setup audio context", ex);
|
logger.debug("Failed to setup audio context", ex);
|
||||||
});
|
});
|
||||||
@@ -120,7 +130,7 @@ export function useAudioContext<S extends string>(
|
|||||||
});
|
});
|
||||||
setAudioContext(undefined);
|
setAudioContext(undefined);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [soundCache, props.latencyHint]);
|
||||||
|
|
||||||
// Update the sink ID whenever we change devices.
|
// Update the sink ID whenever we change devices.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -235,12 +235,3 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
|||||||
...config,
|
...config,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockMediaPlay(): string[] {
|
|
||||||
const audioIsPlaying: string[] = [];
|
|
||||||
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
|
|
||||||
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
|
|
||||||
return Promise.resolve();
|
|
||||||
};
|
|
||||||
return audioIsPlaying;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -203,4 +203,8 @@ export class MockRoom extends EventEmitter {
|
|||||||
});
|
});
|
||||||
return evt.getId()!;
|
return evt.getId()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getMember() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user