From d3cecccdabc772d02e77538771d5cba5b60127c8 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 5 Nov 2024 19:01:57 +0000 Subject: [PATCH] Add tests for ReactionAudioRenderer --- src/button/ReactionToggleButton.test.tsx | 4 +- src/button/ReactionToggleButton.tsx | 3 - src/room/InCallView.tsx | 2 +- src/room/ReactionAudioRenderer.test.tsx | 101 +++++++++++++++++++++++ src/room/ReactionAudioRenderer.tsx | 19 ++++- src/useReactions.test.tsx | 20 ++--- src/utils/testReactions.tsx | 53 +++++++++--- 7 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 src/room/ReactionAudioRenderer.test.tsx diff --git a/src/button/ReactionToggleButton.test.tsx b/src/button/ReactionToggleButton.test.tsx index 8d750e4f..18bcf080 100644 --- a/src/button/ReactionToggleButton.test.tsx +++ b/src/button/ReactionToggleButton.test.tsx @@ -103,7 +103,7 @@ test("Can can lower hand", () => { const { getByRole, getByText, container } = render( , ); - const reactionEvent = room.testSendReaction(memberEventAlice, membership); + const reactionEvent = room.testSendHandRaise(memberEventAlice, membership); act(() => { getByRole("button").click(); }); @@ -185,7 +185,7 @@ test("Can search for and send emoji with the keyboard", async () => { const user = userEvent.setup(); const room = new MockRoom(memberUserIdAlice); const rtcSession = new MockRTCSession(room, membership); - const { getByText, getByRole, getByPlaceholderText, container } = render( + const { getByRole, getByPlaceholderText, container } = render( , ); act(() => { diff --git a/src/button/ReactionToggleButton.tsx b/src/button/ReactionToggleButton.tsx index fbfe1d36..f61b7289 100644 --- a/src/button/ReactionToggleButton.tsx +++ b/src/button/ReactionToggleButton.tsx @@ -21,12 +21,9 @@ import { ChangeEventHandler, ComponentPropsWithoutRef, FC, - FormEventHandler, - KeyboardEvent, KeyboardEventHandler, ReactNode, useCallback, - useEffect, useMemo, useState, } from "react"; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index fb64cb88..9c9c8383 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -236,7 +236,7 @@ export const InCallView: FC = ({ containerRef1, toggleMicrophone, toggleCamera, - (muted) => muteStates.audio.setEnabled?.(!muteprefered), + (muted) => muteStates.audio.setEnabled?.(!muted), ); const mobile = boundsValid && bounds.width <= 660; diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx new file mode 100644 index 00000000..6be08a5b --- /dev/null +++ b/src/room/ReactionAudioRenderer.test.tsx @@ -0,0 +1,101 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { act, render } from "@testing-library/react"; +import { expect, test } from "vitest"; +import { TooltipProvider } from "@vector-im/compound-web"; + +import { + MockRoom, + MockRTCSession, + TestReactionsWrapper, +} from "../utils/testReactions"; +import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; +import { ReactionSet } from "../reactions"; + +const memberUserIdAlice = "@alice:example.org"; +const memberUserIdBob = "@bob:example.org"; +const memberUserIdCharlie = "@charlie:example.org"; +const memberEventAlice = "$membership-alice:example.org"; +const memberEventBob = "$membership-bob:example.org"; +const memberEventCharlie = "$membership-charlie:example.org"; + +const membership: Record = { + [memberEventAlice]: memberUserIdAlice, + [memberEventBob]: memberUserIdBob, + [memberEventCharlie]: memberUserIdCharlie, +}; + +function TestComponent({ rtcSession }: { rtcSession: MockRTCSession }) { + return ( + + + + + + ); +} + +test("defaults to no audio elements", async () => { + const rtcSession = new MockRTCSession( + new MockRoom(memberUserIdAlice), + membership, + ); + const { container } = render(); + expect(container.getElementsByTagName("audio")).toHaveLength(0); +}); + +test("will play an audio sound when there is a reaction", async () => { + const room = new MockRoom(memberUserIdAlice); + const rtcSession = new MockRTCSession(room, membership); + const { container } = render(); + + // 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), + ); + const elements = container.getElementsByTagName("audio"); + expect(elements).toHaveLength(1); + const audioElement = elements[0]; + + expect(audioElement.autoplay).toBe(true); + + const sources = audioElement.getElementsByTagName("source"); + expect(sources).toHaveLength(2); + + // The element will be the full URL, whereas the chosenReaction will have the path. + expect(sources[0].src).toContain(chosenReaction.sound?.ogg); + expect(sources[1].src).toContain(chosenReaction.sound?.mp3); +}); + +test("will play multiple audio sounds when there are multiple different reactions", async () => { + const room = new MockRoom(memberUserIdAlice); + const rtcSession = new MockRTCSession(room, membership); + const { container } = render(); + + // Find the first reaction with a sound effect + const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound); + if (!reaction1 || !reaction2) { + throw Error( + "No reactions have sounds configured, this test cannot succeed", + ); + } + act(() => { + room.testSendReaction(memberEventAlice, reaction1, membership); + room.testSendReaction(memberEventBob, reaction2, membership); + room.testSendReaction(memberEventCharlie, reaction1, membership); + }); + const elements = container.getElementsByTagName("audio"); + // Do not play the same reaction twice. + expect(elements).toHaveLength(2); +}); diff --git a/src/room/ReactionAudioRenderer.tsx b/src/room/ReactionAudioRenderer.tsx index 04f4bf8b..e362b606 100644 --- a/src/room/ReactionAudioRenderer.tsx +++ b/src/room/ReactionAudioRenderer.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import { ReactNode } from "react"; +import { ReactNode, useMemo } from "react"; import { useReactions } from "../useReactions"; import { playReactionsSound, useSetting } from "../settings/settings"; @@ -14,9 +14,20 @@ export function ReactionsAudioRenderer(): ReactNode { const { reactions } = useReactions(); const [shouldPlay] = useSetting(playReactionsSound); - const expectedReactions = shouldPlay - ? [...new Set([...Object.values(reactions)])] - : []; + const expectedReactions = useMemo(() => { + if (!shouldPlay) { + return []; + } + const reactionsToPlayNames = new Set(); + return Object.values(reactions).filter((r) => { + if (reactionsToPlayNames.has(r.name)) { + return false; + } + reactionsToPlayNames.add(r.name); + return true; + }); + }, [shouldPlay, reactions]); + return ( <> {expectedReactions.map( diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx index f9b323fd..6e5d06de 100644 --- a/src/useReactions.test.tsx +++ b/src/useReactions.test.tsx @@ -12,7 +12,7 @@ import { RoomEvent } from "matrix-js-sdk/src/matrix"; import { useReactions } from "./useReactions"; import { - createReaction, + createHandRaisedReaction, createRedaction, MockRoom, MockRTCSession, @@ -75,7 +75,7 @@ describe("useReactions", () => { , ); - await act(() => room.testSendReaction(memberEventAlice, membership)); + await act(() => room.testSendHandRaise(memberEventAlice, membership)); expect(queryByText("Local reaction")).toBeTruthy(); }); test("handles incoming raised hand", async () => { @@ -86,9 +86,9 @@ describe("useReactions", () => { , ); - await act(() => room.testSendReaction(memberEventAlice, membership)); + await act(() => room.testSendHandRaise(memberEventAlice, membership)); expect(queryByRole("list")?.children).to.have.lengthOf(1); - await act(() => room.testSendReaction(memberEventBob, membership)); + await act(() => room.testSendHandRaise(memberEventBob, membership)); expect(queryByRole("list")?.children).to.have.lengthOf(2); }); test("handles incoming unraised hand", async () => { @@ -100,7 +100,7 @@ describe("useReactions", () => { , ); const reactionEventId = await act(() => - room.testSendReaction(memberEventAlice, membership), + room.testSendHandRaise(memberEventAlice, membership), ); expect(queryByRole("list")?.children).to.have.lengthOf(1); await act(() => @@ -115,7 +115,7 @@ describe("useReactions", () => { }); test("handles loading prior raised hand events", () => { const room = new MockRoom(memberUserIdAlice, [ - createReaction(memberEventAlice, membership), + createHandRaisedReaction(memberEventAlice, membership), ]); const rtcSession = new MockRTCSession(room, membership); const { queryByRole } = render( @@ -129,7 +129,7 @@ describe("useReactions", () => { // the raised hand event. test("will remove reaction when a member leaves the call", () => { const room = new MockRoom(memberUserIdAlice, [ - createReaction(memberEventAlice, membership), + createHandRaisedReaction(memberEventAlice, membership), ]); const rtcSession = new MockRTCSession(room, membership); const { queryByRole } = render( @@ -143,7 +143,7 @@ describe("useReactions", () => { }); test("will remove reaction when a member joins via a new event", () => { const room = new MockRoom(memberUserIdAlice, [ - createReaction(memberEventAlice, membership), + createHandRaisedReaction(memberEventAlice, membership), ]); const rtcSession = new MockRTCSession(room, membership); const { queryByRole } = render( @@ -161,7 +161,7 @@ describe("useReactions", () => { }); test("ignores invalid sender for historic event", () => { const room = new MockRoom(memberUserIdAlice, [ - createReaction(memberEventAlice, memberUserIdBob), + createHandRaisedReaction(memberEventAlice, memberUserIdBob), ]); const rtcSession = new MockRTCSession(room, membership); const { queryByRole } = render( @@ -179,7 +179,7 @@ describe("useReactions", () => { , ); - await act(() => room.testSendReaction(memberEventAlice, memberUserIdBob)); + await act(() => room.testSendHandRaise(memberEventAlice, memberUserIdBob)); expect(queryByRole("list")?.children).to.have.lengthOf(0); }); }); diff --git a/src/utils/testReactions.tsx b/src/utils/testReactions.tsx index 8efbce41..99557e8a 100644 --- a/src/utils/testReactions.tsx +++ b/src/utils/testReactions.tsx @@ -9,7 +9,7 @@ import { PropsWithChildren, ReactNode } from "react"; import { randomUUID } from "crypto"; import EventEmitter from "events"; import { MatrixClient } from "matrix-js-sdk/src/client"; -import { EventType, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { EventType, RoomEvent, RelationType } from "matrix-js-sdk/src/matrix"; import { MatrixEvent, EventTimeline, @@ -22,6 +22,11 @@ import { } from "matrix-js-sdk/src/matrixrtc"; import { ReactionsProvider } from "../useReactions"; +import { + ECallReactionEventContent, + ElementCallReactionEventType, + ReactionOption, +} from "../reactions"; export const TestReactionsWrapper = ({ rtcSession, @@ -70,7 +75,7 @@ export class MockRTCSession extends EventEmitter { } } -export function createReaction( +export function createHandRaisedReaction( parentMemberEvent: string, membershipOrOverridenSender: Record | string, ): MatrixEvent { @@ -149,19 +154,43 @@ export class MockRoom extends EventEmitter { } as unknown as Room["relations"]; } - public testSendReaction( - parentMemberEvent: string, - overridenSender: string, - ): string; - public testSendReaction( - parentMemberEvent: string, - membershipOrOverridenSender: Record, - ): string; - public testSendReaction( + public testSendHandRaise( parentMemberEvent: string, membershipOrOverridenSender: Record | string, ): string { - const evt = createReaction(parentMemberEvent, membershipOrOverridenSender); + const evt = createHandRaisedReaction( + parentMemberEvent, + membershipOrOverridenSender, + ); + this.emit(RoomEvent.Timeline, evt, this, undefined, false, { + timeline: new EventTimeline(new EventTimelineSet(undefined)), + }); + return evt.getId()!; + } + + public testSendReaction( + parentMemberEvent: string, + reaction: ReactionOption, + membershipOrOverridenSender: Record | string, + ): string { + const evt = new MatrixEvent({ + sender: + typeof membershipOrOverridenSender === "string" + ? membershipOrOverridenSender + : membershipOrOverridenSender[parentMemberEvent], + type: ElementCallReactionEventType, + origin_server_ts: new Date().getTime(), + content: { + "m.relates_to": { + rel_type: RelationType.Reference, + event_id: parentMemberEvent, + }, + emoji: reaction.emoji, + name: reaction.name, + } satisfies ECallReactionEventContent, + event_id: randomUUID(), + }); + this.emit(RoomEvent.Timeline, evt, this, undefined, false, { timeline: new EventTimeline(new EventTimelineSet(undefined)), });