All the test bits and pieces

This commit is contained in:
Half-Shot
2024-12-12 09:00:17 +00:00
parent 12a412ce11
commit 19e5c67a37
12 changed files with 365 additions and 271 deletions

View File

@@ -5,47 +5,45 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { render } from "@testing-library/react"; import { act, render } from "@testing-library/react";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event"; import { userEvent } from "@testing-library/user-event";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { import { MockRoom } from "../utils/testReactions";
MockRoom,
MockRTCSession,
TestReactionsWrapper,
} from "../utils/testReactions";
import { ReactionToggleButton } from "./ReactionToggleButton"; import { ReactionToggleButton } from "./ReactionToggleButton";
import { ElementCallReactionEventType } from "../reactions"; import { ElementCallReactionEventType } from "../reactions";
import { CallViewModel } from "../state/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { MockRTCSession } from "../utils/test";
import { ReactionsProvider } from "../useReactions";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
const memberUserIdAlice = "@alice:example.org"; const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`;
const memberEventAlice = "$membership-alice:example.org";
const membership: Record<string, string> = {
[memberEventAlice]: memberUserIdAlice,
};
function TestComponent({ function TestComponent({
rtcSession, rtcSession,
vm,
}: { }: {
rtcSession: MockRTCSession; rtcSession: MockRTCSession;
vm: CallViewModel;
}): ReactNode { }): ReactNode {
return ( return (
<TooltipProvider> <TooltipProvider>
<TestReactionsWrapper rtcSession={rtcSession}> <ReactionsProvider rtcSession={rtcSession as unknown as MatrixRTCSession}>
<ReactionToggleButton userId={memberUserIdAlice} /> <ReactionToggleButton vm={vm} identifier={localIdent} />
</TestReactionsWrapper> </ReactionsProvider>
</TooltipProvider> </TooltipProvider>
); );
} }
test("Can open menu", async () => { test("Can open menu", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice); const { vm, rtcSession } = getBasicCallViewModelEnvironment([alice]);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render( const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} />, <TestComponent vm={vm} rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
@@ -53,40 +51,68 @@ test("Can open menu", async () => {
test("Can raise hand", async () => { test("Can raise hand", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice); const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render( const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} />, <TestComponent vm={vm} rtcSession={rtcSession} />,
); );
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.raise_hand")); await user.click(getByLabelText("action.raise_hand"));
expect(room.testSentEvents).toEqual([ expect(rtcSession.room.client.sendEvent).toHaveBeenCalledWith(
[ undefined,
undefined, "m.reaction",
"m.reaction", {
{ "m.relates_to": {
"m.relates_to": { event_id: localRtcMember.eventId,
event_id: memberEventAlice, key: "🖐️",
key: "🖐️", rel_type: "m.annotation",
rel_type: "m.annotation",
},
}, },
], },
]); );
await act(() => {
vm.updateReactions({
raisedHands: {
[localIdent]: new Date(),
},
reactions: {},
});
});
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
test("Can lower hand", async () => { test.only("Can lower hand", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice); const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render( const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} />, <TestComponent vm={vm} rtcSession={rtcSession} />,
); );
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
await user.click(getByLabelText("common.reactions")); await user.click(getByLabelText("common.reactions"));
await user.click(getByLabelText("action.raise_hand"));
await act(() => {
vm.updateReactions({
raisedHands: {
[localIdent]: new Date(),
},
reactions: {},
});
});
await user.click(getByLabelText("action.lower_hand")); await user.click(getByLabelText("action.lower_hand"));
expect(room.testRedactedEvents).toEqual([[undefined, reactionEvent]]); expect(rtcSession.room.client.redactEvent).toHaveBeenCalledWith(
undefined,
"m.reaction",
{
"m.relates_to": {
event_id: localRtcMember.eventId,
key: "🖐️",
rel_type: "m.annotation",
},
},
);
await act(() => {
vm.updateReactions({
raisedHands: {},
reactions: {},
});
});
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });

View File

@@ -137,9 +137,9 @@ exports[`Can raise hand 1`] = `
aria-disabled="false" aria-disabled="false"
aria-expanded="false" aria-expanded="false"
aria-haspopup="true" aria-haspopup="true"
aria-labelledby=":r1j:" aria-labelledby=":r0:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59" class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="secondary" data-kind="primary"
data-size="lg" data-size="lg"
role="button" role="button"
tabindex="0" tabindex="0"
@@ -153,9 +153,7 @@ exports[`Can raise hand 1`] = `
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
clip-rule="evenodd" d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.944 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 2 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0V3Z"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
fill-rule="evenodd"
/> />
</svg> </svg>
</button> </button>

View File

@@ -14,44 +14,24 @@ import {
test, test,
vitest, vitest,
} from "vitest"; } from "vitest";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { ConnectionState } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { afterEach } from "node:test"; import { afterEach } from "node:test";
import { act, ReactNode } from "react"; import { act } from "react";
import { import { CallMembership } from "matrix-js-sdk/src/matrixrtc";
CallMembership,
type MatrixRTCSession,
} from "matrix-js-sdk/src/matrixrtc";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { import { mockRtcMembership } from "../utils/test";
mockLivekitRoom,
mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember,
mockRemoteParticipant,
mockRtcMembership,
MockRTCSession,
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel";
import { import {
CallEventAudioRenderer, CallEventAudioRenderer,
MAX_PARTICIPANT_COUNT_FOR_SOUND, MAX_PARTICIPANT_COUNT_FOR_SOUND,
} from "./CallEventAudioRenderer"; } from "./CallEventAudioRenderer";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { TestReactionsWrapper } from "../utils/testReactions";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC"); import {
const local = mockMatrixRoomMember(localRtcMember); alice,
const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA"); aliceRtcMember,
const alice = mockMatrixRoomMember(aliceRtcMember); bobRtcMember,
const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB"); local,
const localParticipant = mockLocalParticipant({ identity: "" }); } from "../utils/test-fixtures";
const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`;
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
vitest.mock("../useAudioContext"); vitest.mock("../useAudioContext");
vitest.mock("../soundUtils"); vitest.mock("../soundUtils");
@@ -78,66 +58,6 @@ beforeEach(() => {
}); });
}); });
function TestComponent({
rtcSession,
vm,
}: {
rtcSession: MockRTCSession;
vm: CallViewModel;
}): ReactNode {
return (
<TestReactionsWrapper
rtcSession={rtcSession as unknown as MatrixRTCSession}
>
<CallEventAudioRenderer vm={vm} />
</TestReactionsWrapper>
);
}
function getMockEnv(
members: RoomMember[],
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
): {
vm: CallViewModel;
session: MockRTCSession;
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
} {
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
const remoteParticipants = of([aliceParticipant]);
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
);
const matrixRoom = mockMatrixRoom({
client: {
getUserId: () => localRtcMember.sender,
getDeviceId: () => localRtcMember.deviceId,
on: vitest.fn(),
off: vitest.fn(),
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
});
const remoteRtcMemberships = new BehaviorSubject<CallMembership[]>(
initialRemoteRtcMemberships,
);
const session = new MockRTCSession(
matrixRoom,
localRtcMember,
).withMemberships(remoteRtcMemberships);
const vm = new CallViewModel(
session as unknown as MatrixRTCSession,
liveKitRoom,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
return { vm, session, remoteRtcMemberships };
}
/** /**
* 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
@@ -146,8 +66,12 @@ function getMockEnv(
* a noise every time. * a noise every time.
*/ */
test("plays one sound when entering a call", () => { test("plays one sound when entering a call", () => {
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); const { vm, remoteRtcMemberships } = getBasicCallViewModelEnvironment([
render(<TestComponent rtcSession={session} vm={vm} />); local,
alice,
]);
render(<CallEventAudioRenderer vm={vm} />);
// Joining a call usually means remote participants are added later. // Joining a call usually means remote participants are added later.
act(() => { act(() => {
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
@@ -155,10 +79,12 @@ test("plays one sound when entering a call", () => {
expect(playSound).toHaveBeenCalledOnce(); expect(playSound).toHaveBeenCalledOnce();
}); });
// TODO: Same test?
test("plays a sound when a user joins", () => { test("plays a sound when a user joins", () => {
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); const { vm, remoteRtcMemberships } = getBasicCallViewModelEnvironment([
render(<TestComponent rtcSession={session} vm={vm} />); local,
alice,
]);
render(<CallEventAudioRenderer vm={vm} />);
act(() => { act(() => {
remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]); remoteRtcMemberships.next([aliceRtcMember, bobRtcMember]);
@@ -168,8 +94,11 @@ test("plays a sound when a user joins", () => {
}); });
test("plays a sound when a user leaves", () => { test("plays a sound when a user leaves", () => {
const { session, vm, remoteRtcMemberships } = getMockEnv([local, alice]); const { vm, remoteRtcMemberships } = getBasicCallViewModelEnvironment([
render(<TestComponent rtcSession={session} vm={vm} />); local,
alice,
]);
render(<CallEventAudioRenderer vm={vm} />);
act(() => { act(() => {
remoteRtcMemberships.next([]); remoteRtcMemberships.next([]);
@@ -185,12 +114,12 @@ test("plays no sound when the participant list is more than the maximum size", (
); );
} }
const { session, vm, remoteRtcMemberships } = getMockEnv( const { vm, remoteRtcMemberships } = getBasicCallViewModelEnvironment(
[local, alice], [local, alice],
mockRtcMemberships, mockRtcMemberships,
); );
render(<TestComponent rtcSession={session} vm={vm} />); render(<CallEventAudioRenderer vm={vm} />);
expect(playSound).not.toBeCalled(); expect(playSound).not.toBeCalled();
act(() => { act(() => {
remoteRtcMemberships.next( remoteRtcMemberships.next(
@@ -201,9 +130,9 @@ test("plays no sound when the participant list is more than the maximum size", (
}); });
test("plays one sound when a hand is raised", () => { test("plays one sound when a hand is raised", () => {
const { session, vm } = getMockEnv([local, alice]); const { vm } = getBasicCallViewModelEnvironment([local, alice]);
render(<TestComponent rtcSession={session} vm={vm} />); render(<CallEventAudioRenderer vm={vm} />);
// Joining a call usually means remote participants are added later.
act(() => { act(() => {
vm.updateReactions({ vm.updateReactions({
raisedHands: { raisedHands: {
@@ -216,9 +145,9 @@ test("plays one sound when a hand is raised", () => {
}); });
test("should not play a sound when a hand raise is retracted", () => { test("should not play a sound when a hand raise is retracted", () => {
const { session, vm } = getMockEnv([local, alice]); const { vm } = getBasicCallViewModelEnvironment([local, alice]);
render(<TestComponent rtcSession={session} vm={vm} />); render(<CallEventAudioRenderer vm={vm} />);
// Joining a call usually means remote participants are added later.
act(() => { act(() => {
vm.updateReactions({ vm.updateReactions({
raisedHands: { raisedHands: {

View File

@@ -7,45 +7,19 @@ Please see LICENSE in the repository root for full details.
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web"; import { act } from "react";
import { act, ReactNode } from "react";
import { afterEach } from "node:test"; import { afterEach } from "node:test";
import {
MockRoom,
MockRTCSession,
TestReactionsWrapper,
} from "../utils/testReactions";
import { showReactions } from "../settings/settings"; import { showReactions } from "../settings/settings";
import { ReactionsOverlay } from "./ReactionsOverlay"; import { ReactionsOverlay } from "./ReactionsOverlay";
import { ReactionSet } from "../reactions"; import { ReactionSet } from "../reactions";
import {
const memberUserIdAlice = "@alice:example.org"; local,
const memberUserIdBob = "@bob:example.org"; alice,
const memberUserIdCharlie = "@charlie:example.org"; aliceRtcMember,
const memberEventAlice = "$membership-alice:example.org"; bobRtcMember,
const memberEventBob = "$membership-bob:example.org"; } from "../utils/test-fixtures";
const memberEventCharlie = "$membership-charlie:example.org"; import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
const membership: Record<string, string> = {
[memberEventAlice]: memberUserIdAlice,
[memberEventBob]: memberUserIdBob,
[memberEventCharlie]: memberUserIdCharlie,
};
function TestComponent({
rtcSession,
}: {
rtcSession: MockRTCSession;
}): ReactNode {
return (
<TooltipProvider>
<TestReactionsWrapper rtcSession={rtcSession}>
<ReactionsOverlay />
</TestReactionsWrapper>
</TooltipProvider>
);
}
afterEach(() => { afterEach(() => {
showReactions.setValue(showReactions.defaultValue); showReactions.setValue(showReactions.defaultValue);
@@ -53,22 +27,21 @@ afterEach(() => {
test("defaults to showing no reactions", () => { test("defaults to showing no reactions", () => {
showReactions.setValue(true); showReactions.setValue(true);
const rtcSession = new MockRTCSession( const { vm } = getBasicCallViewModelEnvironment([local, alice]);
new MockRoom(memberUserIdAlice), const { container } = render(<ReactionsOverlay vm={vm} />);
membership,
);
const { container } = render(<TestComponent rtcSession={rtcSession} />);
expect(container.getElementsByTagName("span")).toHaveLength(0); expect(container.getElementsByTagName("span")).toHaveLength(0);
}); });
test("shows a reaction when sent", () => { test("shows a reaction when sent", () => {
showReactions.setValue(true); showReactions.setValue(true);
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const { getByRole } = render(<ReactionsOverlay vm={vm} />);
const reaction = ReactionSet[0]; const reaction = ReactionSet[0];
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByRole } = render(<TestComponent rtcSession={rtcSession} />);
act(() => { act(() => {
room.testSendReaction(memberEventAlice, reaction, membership); vm.updateReactions({
reactions: { [aliceRtcMember.deviceId]: reaction },
raisedHands: {},
});
}); });
const span = getByRole("presentation"); const span = getByRole("presentation");
expect(getByRole("presentation")).toBeTruthy(); expect(getByRole("presentation")).toBeTruthy();
@@ -78,29 +51,33 @@ test("shows a reaction when sent", () => {
test("shows two of the same reaction when sent", () => { test("shows two of the same reaction when sent", () => {
showReactions.setValue(true); showReactions.setValue(true);
const reaction = ReactionSet[0]; const reaction = ReactionSet[0];
const room = new MockRoom(memberUserIdAlice); const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const rtcSession = new MockRTCSession(room, membership); const { getAllByRole } = render(<ReactionsOverlay vm={vm} />);
const { getAllByRole } = render(<TestComponent rtcSession={rtcSession} />);
act(() => { act(() => {
room.testSendReaction(memberEventAlice, reaction, membership); vm.updateReactions({
}); reactions: {
act(() => { [aliceRtcMember.deviceId]: reaction,
room.testSendReaction(memberEventBob, reaction, membership); [bobRtcMember.deviceId]: reaction,
},
raisedHands: {},
});
}); });
expect(getAllByRole("presentation")).toHaveLength(2); expect(getAllByRole("presentation")).toHaveLength(2);
}); });
test("shows two different reactions when sent", () => { test("shows two different reactions when sent", () => {
showReactions.setValue(true); showReactions.setValue(true);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const [reactionA, reactionB] = ReactionSet; const [reactionA, reactionB] = ReactionSet;
const { getAllByRole } = render(<TestComponent rtcSession={rtcSession} />); const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const { getAllByRole } = render(<ReactionsOverlay vm={vm} />);
act(() => { act(() => {
room.testSendReaction(memberEventAlice, reactionA, membership); vm.updateReactions({
}); reactions: {
act(() => { [aliceRtcMember.deviceId]: reactionA,
room.testSendReaction(memberEventBob, reactionB, membership); [bobRtcMember.deviceId]: reactionB,
},
raisedHands: {},
});
}); });
const [reactionElementA, reactionElementB] = getAllByRole("presentation"); const [reactionElementA, reactionElementB] = getAllByRole("presentation");
expect(reactionElementA.innerHTML).toEqual(reactionA.emoji); expect(reactionElementA.innerHTML).toEqual(reactionA.emoji);
@@ -110,11 +87,13 @@ test("shows two different reactions when sent", () => {
test("hides reactions when reaction animations are disabled", () => { test("hides reactions when reaction animations are disabled", () => {
showReactions.setValue(false); showReactions.setValue(false);
const reaction = ReactionSet[0]; const reaction = ReactionSet[0];
const room = new MockRoom(memberUserIdAlice); const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const rtcSession = new MockRTCSession(room, membership); const { container } = render(<ReactionsOverlay vm={vm} />);
act(() => { act(() => {
room.testSendReaction(memberEventAlice, reaction, membership); vm.updateReactions({
reactions: { [aliceRtcMember.deviceId]: reaction },
raisedHands: {},
});
}); });
const { container } = render(<TestComponent rtcSession={rtcSession} />);
expect(container.getElementsByTagName("span")).toHaveLength(0); expect(container.getElementsByTagName("span")).toHaveLength(0);
}); });

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { test, vi, onTestFinished, it } from "vitest"; import { test, vi, onTestFinished, it, vitest } from "vitest";
import { import {
combineLatest, combineLatest,
debounceTime, debounceTime,
@@ -14,6 +14,7 @@ import {
Observable, Observable,
of, of,
switchMap, switchMap,
tap,
} from "rxjs"; } from "rxjs";
import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { import {
@@ -684,3 +685,64 @@ it("should show at least one tile per MatrixRTCSession", () => {
); );
}); });
}); });
// TODO: Add presenters and speakers?
it("should rank raised hands above video feeds and below speakers and presenters", () => {
withTestScheduler(({ schedule, expectObservable }) => {
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "a";
withCallViewModel(
of([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected),
new Map(),
(vm) => {
schedule("ah", {
a: () => {
// We imagine that only three tiles (the first three) will be visible
// on screen at a time
vm.layout.subscribe((layout) => {
console.log(layout);
if (layout.type === "grid") {
for (let i = 0; i < layout.grid.length; i++)
layout.grid[i].setVisible(i <= 1);
}
});
},
h: () => {
vm.updateReactions({
reactions: {},
raisedHands: {
[`${bobRtcMember.sender}:${bobRtcMember.deviceId}`]: new Date(),
},
});
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
"@bob:example.org:BBBB:0",
"@alice:example.org:AAAA:0",
],
},
h: {
type: "grid",
spotlight: undefined,
grid: [
"local:0",
"@bob:example.org:BBBB:0",
"@alice:example.org:AAAA:0",
],
},
},
);
},
);
});
});

View File

@@ -42,6 +42,7 @@ import {
switchMap, switchMap,
switchScan, switchScan,
take, take,
tap,
timer, timer,
withLatestFrom, withLatestFrom,
} from "rxjs"; } from "rxjs";
@@ -635,13 +636,14 @@ export class CallViewModel extends ViewModel {
[ [
m.speaker, m.speaker,
m.presenter, m.presenter,
m.vm.handRaised,
m.vm.videoEnabled, m.vm.videoEnabled,
m.vm instanceof LocalUserMediaViewModel m.vm instanceof LocalUserMediaViewModel
? m.vm.alwaysShow ? m.vm.alwaysShow
: of(false), : of(false),
m.vm.handRaised,
], ],
(speaker, presenter, handRaised, video, alwaysShow) => { (speaker, presenter, video, alwaysShow, handRaised) => {
console.log(m.vm.id, handRaised);
let bin: SortingBin; let bin: SortingBin;
if (m.vm.local) if (m.vm.local)
bin = alwaysShow bin = alwaysShow
@@ -664,6 +666,12 @@ export class CallViewModel extends ViewModel {
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm), bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
); );
}), }),
tap((v) =>
console.log(
"final grid",
v.map((v) => v.id),
),
),
); );
private readonly spotlight: Observable<MediaViewModel[]> = private readonly spotlight: Observable<MediaViewModel[]> =
@@ -1116,10 +1124,12 @@ export class CallViewModel extends ViewModel {
this.scope.state(), this.scope.state(),
); );
private readonly handsRaisedSubject = new Subject<Record<string, Date>>(); private readonly handsRaisedSubject = new BehaviorSubject<
private readonly reactionsSubject = new Subject< Record<string, Date>
>({});
private readonly reactionsSubject = new BehaviorSubject<
Record<string, ReactionOption> Record<string, ReactionOption>
>(); >({});
public readonly handsRaised = this.handsRaisedSubject.asObservable(); public readonly handsRaised = this.handsRaisedSubject.asObservable();
public readonly reactions = this.reactionsSubject.asObservable(); public readonly reactions = this.reactionsSubject.asObservable();

View File

@@ -15,7 +15,7 @@ import {
createHandRaisedReaction, createHandRaisedReaction,
createRedaction, createRedaction,
MockRoom, MockRoom,
MockRTCSession, ReactionsMockRTCSession,
TestReactionsWrapper, TestReactionsWrapper,
} from "./utils/testReactions"; } from "./utils/testReactions";
@@ -55,7 +55,7 @@ const TestComponent: FC = () => {
describe("useReactions", () => { describe("useReactions", () => {
test("starts with an empty list", () => { test("starts with an empty list", () => {
const rtcSession = new MockRTCSession( const rtcSession = new ReactionsMockRTCSession(
new MockRoom(memberUserIdAlice), new MockRoom(memberUserIdAlice),
membership, membership,
); );
@@ -68,7 +68,7 @@ describe("useReactions", () => {
}); });
test("handles incoming raised hand", async () => { test("handles incoming raised hand", async () => {
const room = new MockRoom(memberUserIdAlice); const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render( const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}> <TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent /> <TestComponent />
@@ -81,7 +81,7 @@ describe("useReactions", () => {
}); });
test("handles incoming unraised hand", async () => { test("handles incoming unraised hand", async () => {
const room = new MockRoom(memberUserIdAlice); const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render( const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}> <TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent /> <TestComponent />
@@ -105,7 +105,7 @@ describe("useReactions", () => {
const room = new MockRoom(memberUserIdAlice, [ const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership), createHandRaisedReaction(memberEventAlice, membership),
]); ]);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render( const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}> <TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent /> <TestComponent />
@@ -119,7 +119,7 @@ describe("useReactions", () => {
const room = new MockRoom(memberUserIdAlice, [ const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership), createHandRaisedReaction(memberEventAlice, membership),
]); ]);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render( const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}> <TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent /> <TestComponent />
@@ -133,7 +133,7 @@ describe("useReactions", () => {
const room = new MockRoom(memberUserIdAlice, [ const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership), createHandRaisedReaction(memberEventAlice, membership),
]); ]);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render( const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}> <TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent /> <TestComponent />
@@ -151,7 +151,7 @@ describe("useReactions", () => {
const room = new MockRoom(memberUserIdAlice, [ const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, memberUserIdBob), createHandRaisedReaction(memberEventAlice, memberUserIdBob),
]); ]);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render( const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}> <TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent /> <TestComponent />
@@ -161,7 +161,7 @@ describe("useReactions", () => {
}); });
test("ignores invalid sender for new event", async () => { test("ignores invalid sender for new event", async () => {
const room = new MockRoom(memberUserIdAlice); const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership); const rtcSession = new ReactionsMockRTCSession(room, membership);
const { queryByRole } = render( const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}> <TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent /> <TestComponent />

View File

@@ -349,6 +349,7 @@ export const ReactionsProvider = ({
]); ]);
const toggleRaisedHand = useCallback(async () => { const toggleRaisedHand = useCallback(async () => {
console.log("toggleRaisedHand", myMembershipIdentifier);
if (!myMembershipIdentifier) { if (!myMembershipIdentifier) {
return; return;
} }

View File

@@ -0,0 +1,17 @@
import {
mockRtcMembership,
mockMatrixRoomMember,
mockRemoteParticipant,
mockLocalParticipant,
} from "./test";
export const aliceRtcMember = mockRtcMembership("@alice:example.org", "AAAA");
export const alice = mockMatrixRoomMember(aliceRtcMember);
export const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`;
export const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
export const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
export const local = mockMatrixRoomMember(localRtcMember);
export const localParticipant = mockLocalParticipant({ identity: "" });
export const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");

View File

@@ -0,0 +1,71 @@
import { ConnectionState } from "livekit-client";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { BehaviorSubject, of } from "rxjs";
import { vitest } from "vitest";
import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel";
import { mockLivekitRoom, mockMatrixRoom, MockRTCSession } from "./test";
import {
aliceRtcMember,
aliceParticipant,
localParticipant,
localRtcMember,
} from "./test-fixtures";
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
/**
* Construct a basic CallViewModel to test components that make use of it.
* @param members
* @param initialRemoteRtcMemberships
* @returns
*/
export function getBasicCallViewModelEnvironment(
members: RoomMember[],
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
): {
vm: CallViewModel;
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
rtcSession: MockRTCSession;
} {
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
const remoteParticipants = of([aliceParticipant]);
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
);
const matrixRoom = mockMatrixRoom({
relations: {
getChildEventsForEvent: vitest.fn(),
} as Partial<RelationsContainer> as RelationsContainer,
client: {
getUserId: () => localRtcMember.sender,
getDeviceId: () => localRtcMember.deviceId,
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
on: vitest.fn(),
off: vitest.fn(),
} as Partial<MatrixClient> as MatrixClient,
getMember: (userId) => matrixRoomMembers.get(userId) ?? null,
});
const remoteRtcMemberships = new BehaviorSubject<CallMembership[]>(
initialRemoteRtcMemberships,
);
const rtcSession = new MockRTCSession(
matrixRoom,
localRtcMember,
).withMemberships(remoteRtcMemberships);
const vm = new CallViewModel(
rtcSession as unknown as MatrixRTCSession,
liveKitRoom,
{
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
);
return { vm, remoteRtcMemberships, rtcSession };
}

View File

@@ -4,19 +4,21 @@ Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { map, Observable, of, SchedulerLike } from "rxjs"; import { BehaviorSubject, map, Observable, of, SchedulerLike } from "rxjs";
import { RunHelpers, TestScheduler } from "rxjs/testing"; import { RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, vi } from "vitest"; import { expect, vi, vitest } from "vitest";
import { import {
RoomMember, RoomMember,
Room as MatrixRoom, Room as MatrixRoom,
MatrixEvent, MatrixEvent,
Room, Room,
TypedEventEmitter, TypedEventEmitter,
MatrixClient,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { import {
CallMembership, CallMembership,
Focus, Focus,
MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
MatrixRTCSessionEventHandlerMap, MatrixRTCSessionEventHandlerMap,
SessionMembershipData, SessionMembershipData,
@@ -27,6 +29,7 @@ import {
RemoteParticipant, RemoteParticipant,
RemoteTrackPublication, RemoteTrackPublication,
Room as LivekitRoom, Room as LivekitRoom,
ConnectionState,
} from "livekit-client"; } from "livekit-client";
import { import {
@@ -36,6 +39,14 @@ import {
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { DEFAULT_CONFIG, ResolvedConfigOptions } from "../config/ConfigOptions"; import { DEFAULT_CONFIG, ResolvedConfigOptions } from "../config/ConfigOptions";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { CallViewModel } from "../state/CallViewModel";
import {
aliceParticipant,
aliceRtcMember,
localParticipant,
localRtcMember,
} from "./test-fixtures";
import { randomUUID } from "crypto";
export function withFakeTimers(continuation: () => void): void { export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -129,6 +140,7 @@ export function mockRtcMembership(
}; };
const event = new MatrixEvent({ const event = new MatrixEvent({
sender: typeof user === "string" ? user : user.userId, sender: typeof user === "string" ? user : user.userId,
event_id: `$-ev-${randomUUID()}:example.org`,
}); });
return new CallMembership(event, data); return new CallMembership(event, data);
} }

View File

@@ -27,53 +27,41 @@ import {
ElementCallReactionEventType, ElementCallReactionEventType,
ReactionOption, ReactionOption,
} from "../reactions"; } from "../reactions";
import { MockRTCSession } from "./test";
export const TestReactionsWrapper = ({ // export class ReactionsMockRTCSession extends EventEmitter {
rtcSession, // public memberships: {
children, // sender: string;
}: PropsWithChildren<{ // eventId: string;
rtcSession: MockRTCSession | MatrixRTCSession; // createdTs: () => Date;
}>): ReactNode => { // }[];
return (
<ReactionsProvider rtcSession={rtcSession as unknown as MatrixRTCSession}>
{children}
</ReactionsProvider>
);
};
export class MockRTCSession extends EventEmitter { // public constructor(
public memberships: { // public readonly room: MockRoom,
sender: string; // membership: Record<string, string>,
eventId: string; // ) {
createdTs: () => Date; // super();
}[]; // this.memberships = Object.entries(membership).map(([eventId, sender]) => ({
// sender,
// eventId,
// createdTs: (): Date => new Date(),
// }));
// }
public constructor( // public testRemoveMember(userId: string): void {
public readonly room: MockRoom, // this.memberships = this.memberships.filter((u) => u.sender !== userId);
membership: Record<string, string>, // this.emit(MatrixRTCSessionEvent.MembershipsChanged);
) { // }
super();
this.memberships = Object.entries(membership).map(([eventId, sender]) => ({
sender,
eventId,
createdTs: (): Date => new Date(),
}));
}
public testRemoveMember(userId: string): void { // public testAddMember(sender: string): void {
this.memberships = this.memberships.filter((u) => u.sender !== userId); // this.memberships.push({
this.emit(MatrixRTCSessionEvent.MembershipsChanged); // sender,
} // eventId: `!fake-${randomUUID()}:event`,
// createdTs: (): Date => new Date(),
public testAddMember(sender: string): void { // });
this.memberships.push({ // this.emit(MatrixRTCSessionEvent.MembershipsChanged);
sender, // }
eventId: `!fake-${randomUUID()}:event`, // }
createdTs: (): Date => new Date(),
});
this.emit(MatrixRTCSessionEvent.MembershipsChanged);
}
}
export function createHandRaisedReaction( export function createHandRaisedReaction(
parentMemberEvent: string, parentMemberEvent: string,
@@ -126,6 +114,7 @@ export class MockRoom extends EventEmitter {
public get client(): MatrixClient { public get client(): MatrixClient {
return { return {
getUserId: (): string => this.ownUserId, getUserId: (): string => this.ownUserId,
getDeviceId: (): string => "ABCDEF",
sendEvent: async ( sendEvent: async (
...props: Parameters<MatrixClient["sendEvent"]> ...props: Parameters<MatrixClient["sendEvent"]>
): ReturnType<MatrixClient["sendEvent"]> => { ): ReturnType<MatrixClient["sendEvent"]> => {