mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-21 00:28:08 +08:00
Merge pull request #2629 from robintown/test-call-vm
Test CallViewModel
This commit is contained in:
commit
cec7fc8f5b
@ -108,7 +108,8 @@
|
||||
"vite": "^5.0.0",
|
||||
"vite-plugin-html-template": "^1.1.0",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^2.0.0"
|
||||
"vitest": "^2.0.0",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"strip-ansi": "6.0.1"
|
||||
|
@ -161,8 +161,8 @@
|
||||
"video_tile": {
|
||||
"always_show": "Always show",
|
||||
"change_fit_contain": "Fit to frame",
|
||||
"exit_full_screen": "Exit full screen",
|
||||
"full_screen": "Full screen",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"mute_for_me": "Mute for me",
|
||||
"volume": "Volume"
|
||||
}
|
||||
|
30
src/Header.test.tsx
Normal file
30
src/Header.test.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { RoomHeaderInfo } from "./Header";
|
||||
|
||||
test("RoomHeaderInfo is accessible", async () => {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<RoomHeaderInfo
|
||||
id="!a:example.org"
|
||||
name="Mission Control"
|
||||
avatarUrl=""
|
||||
encrypted
|
||||
participantCount={11}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Check that the room name acts as a heading
|
||||
screen.getByRole("heading", { name: "Mission Control" });
|
||||
});
|
@ -89,6 +89,9 @@ export const Modal: FC<Props> = ({
|
||||
styles.drawer,
|
||||
{ [styles.tabbed]: tabbed },
|
||||
)}
|
||||
// Suppress the warning about there being no description; the modal
|
||||
// has an accessible title
|
||||
aria-describedby={undefined}
|
||||
{...rest}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
@ -111,7 +114,9 @@ export const Modal: FC<Props> = ({
|
||||
<DialogOverlay
|
||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||
/>
|
||||
<DialogContent asChild {...rest}>
|
||||
{/* Suppress the warning about there being no description; the modal
|
||||
has an accessible title */}
|
||||
<DialogContent asChild aria-describedby={undefined} {...rest}>
|
||||
<Glass
|
||||
className={classNames(
|
||||
className,
|
||||
|
@ -6,19 +6,12 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { render, configure } from "@testing-library/react";
|
||||
import { render } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { Toast } from "../src/Toast";
|
||||
import { withFakeTimers } from "./utils/test";
|
||||
|
||||
configure({
|
||||
defaultHidden: true,
|
||||
});
|
||||
|
||||
// Test Explanation:
|
||||
// This test the toast. We need to use { document: window.document } because the toast listens
|
||||
// for user input on `window`.
|
||||
describe("Toast", () => {
|
||||
test("renders", () => {
|
||||
const { queryByRole } = render(
|
||||
@ -36,7 +29,7 @@ describe("Toast", () => {
|
||||
});
|
||||
|
||||
test("dismisses when Esc is pressed", async () => {
|
||||
const user = userEvent.setup({ document: window.document });
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<Toast open={true} onDismiss={onDismiss}>
|
||||
@ -50,7 +43,7 @@ describe("Toast", () => {
|
||||
test("dismisses when background is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDismiss = vi.fn();
|
||||
const { getByRole, unmount } = render(
|
||||
const { getByRole } = render(
|
||||
<Toast open={true} onDismiss={onDismiss}>
|
||||
Hello world!
|
||||
</Toast>,
|
||||
@ -58,7 +51,6 @@ describe("Toast", () => {
|
||||
const background = getByRole("dialog").previousSibling! as Element;
|
||||
await user.click(background);
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
test("dismisses itself after the specified timeout", () => {
|
||||
|
29
src/input/StarRating.test.tsx
Normal file
29
src/input/StarRating.test.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { StarRatingInput } from "./StarRatingInput";
|
||||
|
||||
test("StarRatingInput is accessible", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<StarRatingInput starCount={5} onChange={onChange} />,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Change the rating to 4 stars
|
||||
await user.click(
|
||||
(
|
||||
await screen.findAllByRole("radio", { name: "star_rating_input_label" })
|
||||
)[3],
|
||||
);
|
||||
expect(onChange).toBeCalledWith(4);
|
||||
});
|
@ -31,7 +31,6 @@ export const EncryptionLock: FC<Props> = ({ encrypted }) => {
|
||||
height={16}
|
||||
className={styles.lock}
|
||||
data-encrypted={encrypted}
|
||||
aria-hidden
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
35
src/room/InviteModal.test.tsx
Normal file
35
src/room/InviteModal.test.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import { axe } from "vitest-axe";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { InviteModal } from "./InviteModal";
|
||||
|
||||
// Used by copy-to-clipboard
|
||||
window.prompt = (): null => null;
|
||||
|
||||
test("InviteModal is accessible", async () => {
|
||||
const user = userEvent.setup();
|
||||
const room = {
|
||||
roomId: "!a:example.org",
|
||||
name: "Mission Control",
|
||||
} as unknown as Room;
|
||||
const onDismiss = vi.fn();
|
||||
const { container } = render(
|
||||
<InviteModal room={room} open={true} onDismiss={onDismiss} />,
|
||||
{ wrapper: BrowserRouter },
|
||||
);
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
await user.click(screen.getByRole("button", { name: "action.copy_link" }));
|
||||
expect(onDismiss).toBeCalled();
|
||||
});
|
276
src/state/CallViewModel.test.ts
Normal file
276
src/state/CallViewModel.test.ts
Normal file
@ -0,0 +1,276 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, vi, onTestFinished } from "vitest";
|
||||
import { map, Observable } from "rxjs";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
ConnectionState,
|
||||
LocalParticipant,
|
||||
RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import * as ComponentsCore from "@livekit/components-core";
|
||||
|
||||
import { CallViewModel, Layout } from "./CallViewModel";
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
mockMatrixRoom,
|
||||
mockMember,
|
||||
mockRemoteParticipant,
|
||||
OurRunHelpers,
|
||||
withTestScheduler,
|
||||
} from "../utils/test";
|
||||
import {
|
||||
ECAddonConnectionState,
|
||||
ECConnectionState,
|
||||
} from "../livekit/useECConnectionState";
|
||||
|
||||
vi.mock("@livekit/components-core");
|
||||
|
||||
const aliceId = "@alice:example.org:AAAA";
|
||||
const bobId = "@bob:example.org:BBBB";
|
||||
|
||||
const alice = mockMember({ userId: "@alice:example.org" });
|
||||
const bob = mockMember({ userId: "@bob:example.org" });
|
||||
const carol = mockMember({ userId: "@carol:example.org" });
|
||||
|
||||
const localParticipant = mockLocalParticipant({ identity: "" });
|
||||
const aliceParticipant = mockRemoteParticipant({ identity: aliceId });
|
||||
const aliceSharingScreen = mockRemoteParticipant({
|
||||
identity: aliceId,
|
||||
isScreenShareEnabled: true,
|
||||
});
|
||||
const bobParticipant = mockRemoteParticipant({ identity: bobId });
|
||||
const bobSharingScreen = mockRemoteParticipant({
|
||||
identity: bobId,
|
||||
isScreenShareEnabled: true,
|
||||
});
|
||||
|
||||
const members = new Map([
|
||||
[alice.userId, alice],
|
||||
[bob.userId, bob],
|
||||
[carol.userId, carol],
|
||||
]);
|
||||
|
||||
export interface GridLayoutSummary {
|
||||
type: "grid";
|
||||
spotlight?: string[];
|
||||
grid: string[];
|
||||
}
|
||||
|
||||
export interface SpotlightLandscapeLayoutSummary {
|
||||
type: "spotlight-landscape";
|
||||
spotlight: string[];
|
||||
grid: string[];
|
||||
}
|
||||
|
||||
export interface SpotlightPortraitLayoutSummary {
|
||||
type: "spotlight-portrait";
|
||||
spotlight: string[];
|
||||
grid: string[];
|
||||
}
|
||||
|
||||
export interface SpotlightExpandedLayoutSummary {
|
||||
type: "spotlight-expanded";
|
||||
spotlight: string[];
|
||||
pip?: string;
|
||||
}
|
||||
|
||||
export interface OneOnOneLayoutSummary {
|
||||
type: "one-on-one";
|
||||
local: string;
|
||||
remote: string;
|
||||
}
|
||||
|
||||
export interface PipLayoutSummary {
|
||||
type: "pip";
|
||||
spotlight: string[];
|
||||
}
|
||||
|
||||
export type LayoutSummary =
|
||||
| GridLayoutSummary
|
||||
| SpotlightLandscapeLayoutSummary
|
||||
| SpotlightPortraitLayoutSummary
|
||||
| SpotlightExpandedLayoutSummary
|
||||
| OneOnOneLayoutSummary
|
||||
| PipLayoutSummary;
|
||||
|
||||
function summarizeLayout(l: Layout): LayoutSummary {
|
||||
switch (l.type) {
|
||||
case "grid":
|
||||
return {
|
||||
type: l.type,
|
||||
spotlight: l.spotlight?.map((vm) => vm.id),
|
||||
grid: l.grid.map((vm) => vm.id),
|
||||
};
|
||||
case "spotlight-landscape":
|
||||
case "spotlight-portrait":
|
||||
return {
|
||||
type: l.type,
|
||||
spotlight: l.spotlight.map((vm) => vm.id),
|
||||
grid: l.grid.map((vm) => vm.id),
|
||||
};
|
||||
case "spotlight-expanded":
|
||||
return {
|
||||
type: l.type,
|
||||
spotlight: l.spotlight.map((vm) => vm.id),
|
||||
pip: l.pip?.id,
|
||||
};
|
||||
case "one-on-one":
|
||||
return { type: l.type, local: l.local.id, remote: l.remote.id };
|
||||
case "pip":
|
||||
return { type: l.type, spotlight: l.spotlight.map((vm) => vm.id) };
|
||||
}
|
||||
}
|
||||
|
||||
function withCallViewModel(
|
||||
{ cold }: OurRunHelpers,
|
||||
remoteParticipants: Observable<RemoteParticipant[]>,
|
||||
connectionState: Observable<ECConnectionState>,
|
||||
continuation: (vm: CallViewModel) => void,
|
||||
): void {
|
||||
const participantsSpy = vi
|
||||
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||
.mockReturnValue(remoteParticipants);
|
||||
const mediaSpy = vi
|
||||
.spyOn(ComponentsCore, "observeParticipantMedia")
|
||||
.mockImplementation((p) =>
|
||||
cold("a", {
|
||||
a: { participant: p } as Partial<
|
||||
ComponentsCore.ParticipantMedia<LocalParticipant>
|
||||
> as ComponentsCore.ParticipantMedia<LocalParticipant>,
|
||||
}),
|
||||
);
|
||||
const eventsSpy = vi
|
||||
.spyOn(ComponentsCore, "observeParticipantEvents")
|
||||
.mockImplementation((p) => cold("a", { a: p }));
|
||||
|
||||
const vm = new CallViewModel(
|
||||
mockMatrixRoom({
|
||||
client: {
|
||||
getUserId: () => "@carol:example.org",
|
||||
} as Partial<MatrixClient> as MatrixClient,
|
||||
getMember: (userId) => members.get(userId) ?? null,
|
||||
}),
|
||||
mockLivekitRoom({ localParticipant }),
|
||||
true,
|
||||
connectionState,
|
||||
);
|
||||
|
||||
onTestFinished(() => {
|
||||
vm!.destroy();
|
||||
participantsSpy!.mockRestore();
|
||||
mediaSpy!.mockRestore();
|
||||
eventsSpy!.mockRestore();
|
||||
});
|
||||
|
||||
continuation(vm);
|
||||
}
|
||||
|
||||
test("participants are retained during a focus switch", () => {
|
||||
withTestScheduler((helpers) => {
|
||||
const { hot, expectObservable } = helpers;
|
||||
// Participants disappear on frame 2 and come back on frame 3
|
||||
const partMarbles = "a-ba";
|
||||
// Start switching focus on frame 1 and reconnect on frame 3
|
||||
const connMarbles = "ab-a";
|
||||
// The visible participants should remain the same throughout the switch
|
||||
const laytMarbles = "aaaa 2997ms a 56998ms a";
|
||||
|
||||
withCallViewModel(
|
||||
helpers,
|
||||
hot(partMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [],
|
||||
}),
|
||||
hot(connMarbles, {
|
||||
a: ConnectionState.Connected,
|
||||
b: ECAddonConnectionState.ECSwitchingFocus,
|
||||
}),
|
||||
(vm) => {
|
||||
expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
|
||||
laytMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("screen sharing activates spotlight layout", () => {
|
||||
withTestScheduler((helpers) => {
|
||||
const { hot, schedule, expectObservable } = helpers;
|
||||
// Start with no screen shares, then have Alice and Bob share their screens,
|
||||
// then return to no screen shares, then have just Alice share for a bit
|
||||
const partMarbles = "abc---d---a-b---a";
|
||||
// While there are no screen shares, switch to spotlight manually, and then
|
||||
// switch back to grid at the end
|
||||
const modeMarbles = "-----------a--------b";
|
||||
// We should automatically enter spotlight for the first round of screen
|
||||
// sharing, then return to grid, then manually go into spotlight, and
|
||||
// remain in spotlight until we manually go back to grid
|
||||
const laytMarbles = "ab(cc)(dd)ae(bb)(ee)a 59979ms a";
|
||||
|
||||
withCallViewModel(
|
||||
helpers,
|
||||
hot(partMarbles, {
|
||||
a: [aliceParticipant, bobParticipant],
|
||||
b: [aliceSharingScreen, bobParticipant],
|
||||
c: [aliceSharingScreen, bobSharingScreen],
|
||||
d: [aliceParticipant, bobSharingScreen],
|
||||
}),
|
||||
hot("a", { a: ConnectionState.Connected }),
|
||||
(vm) => {
|
||||
schedule(modeMarbles, {
|
||||
a: () => vm.setGridMode("spotlight"),
|
||||
b: () => vm.setGridMode("grid"),
|
||||
});
|
||||
|
||||
expectObservable(vm.layout.pipe(map(summarizeLayout))).toBe(
|
||||
laytMarbles,
|
||||
{
|
||||
a: {
|
||||
type: "grid",
|
||||
spotlight: undefined,
|
||||
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
b: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0:screen-share`],
|
||||
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
c: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [
|
||||
`${aliceId}:0:screen-share`,
|
||||
`${bobId}:0:screen-share`,
|
||||
],
|
||||
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
d: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${bobId}:0:screen-share`],
|
||||
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
e: {
|
||||
type: "spotlight-landscape",
|
||||
spotlight: [`${aliceId}:0`],
|
||||
grid: [":0", `${aliceId}:0`, `${bobId}:0`],
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -30,13 +30,13 @@ import {
|
||||
concat,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
forkJoin,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
mergeAll,
|
||||
mergeMap,
|
||||
of,
|
||||
race,
|
||||
sample,
|
||||
scan,
|
||||
skip,
|
||||
startWith,
|
||||
@ -46,7 +46,7 @@ import {
|
||||
take,
|
||||
throttleTime,
|
||||
timer,
|
||||
zip,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
@ -169,10 +169,19 @@ class UserMedia {
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
callEncrypted: boolean,
|
||||
) {
|
||||
this.vm =
|
||||
participant instanceof LocalParticipant
|
||||
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
|
||||
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);
|
||||
this.vm = participant.isLocal
|
||||
? new LocalUserMediaViewModel(
|
||||
id,
|
||||
member,
|
||||
participant as LocalParticipant,
|
||||
callEncrypted,
|
||||
)
|
||||
: new RemoteUserMediaViewModel(
|
||||
id,
|
||||
member,
|
||||
participant as RemoteParticipant,
|
||||
callEncrypted,
|
||||
);
|
||||
|
||||
this.speaker = this.vm.speaking.pipe(
|
||||
// Require 1 s of continuous speaking to become a speaker, and 60 s of
|
||||
@ -186,6 +195,7 @@ class UserMedia {
|
||||
),
|
||||
),
|
||||
startWith(false),
|
||||
distinctUntilChanged(),
|
||||
// Make this Observable hot so that the timers don't reset when you
|
||||
// resubscribe
|
||||
this.scope.state(),
|
||||
@ -256,10 +266,9 @@ export class CallViewModel extends ViewModel {
|
||||
// Lists of participants to "hold" on display, even if LiveKit claims that
|
||||
// they've left
|
||||
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
|
||||
zip(
|
||||
this.connectionState,
|
||||
this.rawRemoteParticipants.pipe(sample(this.connectionState)),
|
||||
(s, ps) => {
|
||||
this.connectionState.pipe(
|
||||
withLatestFrom(this.rawRemoteParticipants),
|
||||
mergeMap(([s, ps]) => {
|
||||
// Whenever we switch focuses, we should retain all the previous
|
||||
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
|
||||
// give their clients time to switch over and avoid jarring layout shifts
|
||||
@ -268,29 +277,19 @@ export class CallViewModel extends ViewModel {
|
||||
// Hold these participants
|
||||
of({ hold: ps }),
|
||||
// Wait for time to pass and the connection state to have changed
|
||||
Promise.all([
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||
forkJoin([
|
||||
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
|
||||
this.connectionState.pipe(
|
||||
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
||||
take(1),
|
||||
),
|
||||
new Promise<void>((resolve) => {
|
||||
const subscription = this.connectionState
|
||||
.pipe(this.scope.bind())
|
||||
.subscribe((s) => {
|
||||
if (s !== ECAddonConnectionState.ECSwitchingFocus) {
|
||||
resolve();
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
});
|
||||
}),
|
||||
// Then unhold them
|
||||
]).then(() => ({ unhold: ps })),
|
||||
]).pipe(map(() => ({ unhold: ps }))),
|
||||
);
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
},
|
||||
).pipe(
|
||||
mergeAll(),
|
||||
}),
|
||||
// Accumulate the hold instructions into a single list showing which
|
||||
// participants are being held
|
||||
accumulate([] as RemoteParticipant[][], (holds, instruction) =>
|
||||
|
@ -5,52 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { LocalParticipant, RemoteParticipant } from "livekit-client";
|
||||
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
} from "./MediaViewModel";
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
withLocalMedia,
|
||||
withRemoteMedia,
|
||||
withTestScheduler,
|
||||
} from "../utils/test";
|
||||
|
||||
function withLocal(continuation: (vm: LocalUserMediaViewModel) => void): void {
|
||||
const member = {} as unknown as RoomMember;
|
||||
const vm = new LocalUserMediaViewModel(
|
||||
"a",
|
||||
member,
|
||||
{} as unknown as LocalParticipant,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function withRemote(
|
||||
participant: Partial<RemoteParticipant>,
|
||||
continuation: (vm: RemoteUserMediaViewModel) => void,
|
||||
): void {
|
||||
const member = {} as unknown as RoomMember;
|
||||
const vm = new RemoteUserMediaViewModel(
|
||||
"a",
|
||||
member,
|
||||
{ setVolume() {}, ...participant } as RemoteParticipant,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
test("set a participant's volume", () => {
|
||||
test("set a participant's volume", async () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
withRemote({ setVolume: setVolumeSpy }, (vm) =>
|
||||
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", {
|
||||
a() {
|
||||
@ -63,9 +28,9 @@ test("set a participant's volume", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("mute and unmute a participant", () => {
|
||||
test("mute and unmute a participant", async () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
withRemote({ setVolume: setVolumeSpy }, (vm) =>
|
||||
await withRemoteMedia({}, { setVolume: setVolumeSpy }, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-abc|", {
|
||||
a() {
|
||||
@ -90,8 +55,8 @@ test("mute and unmute a participant", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("toggle fit/contain for a participant's video", () => {
|
||||
withRemote({}, (vm) =>
|
||||
test("toggle fit/contain for a participant's video", async () => {
|
||||
await withRemoteMedia({}, {}, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab|", {
|
||||
a: () => vm.toggleFitContain(),
|
||||
@ -106,15 +71,15 @@ test("toggle fit/contain for a participant's video", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("local media remembers whether it should always be shown", () => {
|
||||
withLocal((vm) =>
|
||||
test("local media remembers whether it should always be shown", async () => {
|
||||
await withLocalMedia({}, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
||||
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false });
|
||||
}),
|
||||
);
|
||||
// Next local media should start out *not* always shown
|
||||
withLocal((vm) =>
|
||||
await withLocalMedia({}, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
||||
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
|
||||
|
43
src/tile/GridTile.test.tsx
Normal file
43
src/tile/GridTile.test.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { RemoteTrackPublication } from "livekit-client";
|
||||
import { test, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
|
||||
import { GridTile } from "./GridTile";
|
||||
import { withRemoteMedia } from "../utils/test";
|
||||
|
||||
test("GridTile is accessible", async () => {
|
||||
await withRemoteMedia(
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
{
|
||||
setVolume() {},
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||
},
|
||||
async (vm) => {
|
||||
const { container } = render(
|
||||
<GridTile
|
||||
vm={vm}
|
||||
onOpenProfile={() => {}}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
showVideo
|
||||
showSpeakingIndicators
|
||||
/>,
|
||||
);
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Name should be visible
|
||||
screen.getByText("Alice");
|
||||
},
|
||||
);
|
||||
});
|
@ -101,7 +101,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
aria-hidden
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
73
src/tile/SpotlightTile.test.tsx
Normal file
73
src/tile/SpotlightTile.test.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect, vi } from "vitest";
|
||||
import { isInaccessible, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import { withLocalMedia, withRemoteMedia } from "../utils/test";
|
||||
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
public unobserve(): void {}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("SpotlightTile is accessible", async () => {
|
||||
await withRemoteMedia(
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||
},
|
||||
{},
|
||||
async (vm1) => {
|
||||
await withLocalMedia(
|
||||
{
|
||||
rawDisplayName: "Bob",
|
||||
getMxcAvatarUrl: () => "mxc://dlskf",
|
||||
},
|
||||
async (vm2) => {
|
||||
const user = userEvent.setup();
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vms={[vm1, vm2]}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
maximised={false}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
// Alice should be in the spotlight, with her name and avatar on the
|
||||
// first page
|
||||
screen.getByText("Alice");
|
||||
const aliceAvatar = screen.getByRole("img");
|
||||
expect(screen.queryByRole("button", { name: "common.back" })).toBe(
|
||||
null,
|
||||
);
|
||||
// Bob should be out of the spotlight, and therefore invisible
|
||||
expect(isInaccessible(screen.getByText("Bob"))).toBe(true);
|
||||
// Now navigate to Bob
|
||||
await user.click(screen.getByRole("button", { name: "common.next" }));
|
||||
screen.getByText("Bob");
|
||||
expect(screen.getByRole("img")).not.toBe(aliceAvatar);
|
||||
expect(isInaccessible(screen.getByText("Alice"))).toBe(true);
|
||||
// Can toggle whether the tile is expanded
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "video_tile.expand" }),
|
||||
);
|
||||
expect(toggleExpanded).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
@ -52,6 +52,7 @@ interface SpotlightItemBaseProps {
|
||||
member: RoomMember | undefined;
|
||||
unencryptedWarning: boolean;
|
||||
displayName: string;
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
@ -109,10 +110,21 @@ interface SpotlightItemProps {
|
||||
* Whether this item should act as a scroll snapping point.
|
||||
*/
|
||||
snap: boolean;
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
|
||||
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => {
|
||||
(
|
||||
{
|
||||
vm,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
intersectionObserver,
|
||||
snap,
|
||||
"aria-hidden": ariaHidden,
|
||||
},
|
||||
theirRef,
|
||||
) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useDisplayName(vm);
|
||||
@ -144,6 +156,7 @@ const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
"aria-hidden": ariaHidden,
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
@ -271,7 +284,12 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
intersectionObserver={intersectionObserver}
|
||||
// This is how we get the container to scroll to the right media
|
||||
// when the previous/next buttons are clicked: we temporarily
|
||||
// remove all scroll snap points except for just the one media
|
||||
// that we want to bring into view
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -279,9 +297,7 @@ export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded
|
||||
? t("video_tile.full_screen")
|
||||
: t("video_tile.exit_full_screen")
|
||||
expanded ? t("video_tile.collapse") : t("video_tile.expand")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
|
@ -16,8 +16,6 @@ import { useCallViewKeyboardShortcuts } from "../src/useCallViewKeyboardShortcut
|
||||
// Test Explanation:
|
||||
// - The main objective is to test `useCallViewKeyboardShortcuts`.
|
||||
// The TestComponent just wraps a button around that hook.
|
||||
// - We need to set `userEvent` to the `{document = window.document}` since we are testing the
|
||||
// `useCallViewKeyboardShortcuts` hook here. Which is listening to window.
|
||||
|
||||
interface TestComponentProps {
|
||||
setMicrophoneMuted: (muted: boolean) => void;
|
||||
@ -43,7 +41,7 @@ const TestComponent: FC<TestComponentProps> = ({
|
||||
};
|
||||
|
||||
test("spacebar unmutes", async () => {
|
||||
const user = userEvent.setup({ document: window.document });
|
||||
const user = userEvent.setup();
|
||||
let muted = true;
|
||||
render(
|
||||
<TestComponent
|
||||
@ -62,7 +60,7 @@ test("spacebar unmutes", async () => {
|
||||
});
|
||||
|
||||
test("spacebar prioritizes pressing a button", async () => {
|
||||
const user = userEvent.setup({ document: window.document });
|
||||
const user = userEvent.setup();
|
||||
|
||||
const setMuted = vi.fn();
|
||||
const onClick = vi.fn();
|
||||
@ -77,7 +75,7 @@ test("spacebar prioritizes pressing a button", async () => {
|
||||
});
|
||||
|
||||
test("unmuting happens in place of the default action", async () => {
|
||||
const user = userEvent.setup({ document: window.document });
|
||||
const user = userEvent.setup();
|
||||
const defaultPrevented = vi.fn();
|
||||
// In the real application, we mostly just want the spacebar shortcut to avoid
|
||||
// scrolling the page. But to test that here in JSDOM, we need some kind of
|
||||
|
@ -7,6 +7,19 @@ Please see LICENSE in the repository root for full details.
|
||||
import { map } from "rxjs";
|
||||
import { RunHelpers, TestScheduler } from "rxjs/testing";
|
||||
import { expect, vi } from "vitest";
|
||||
import { RoomMember, Room as MatrixRoom } from "matrix-js-sdk/src/matrix";
|
||||
import {
|
||||
LocalParticipant,
|
||||
LocalTrackPublication,
|
||||
RemoteParticipant,
|
||||
RemoteTrackPublication,
|
||||
Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
|
||||
export function withFakeTimers(continuation: () => void): void {
|
||||
vi.useFakeTimers();
|
||||
@ -49,3 +62,102 @@ export function withTestScheduler(
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
interface EmitterMock<T> {
|
||||
on: () => T;
|
||||
off: () => T;
|
||||
addListener: () => T;
|
||||
removeListener: () => T;
|
||||
}
|
||||
|
||||
function mockEmitter<T>(): EmitterMock<T> {
|
||||
return {
|
||||
on(): T {
|
||||
return this as T;
|
||||
},
|
||||
off(): T {
|
||||
return this as T;
|
||||
},
|
||||
addListener(): T {
|
||||
return this as T;
|
||||
},
|
||||
removeListener(): T {
|
||||
return this as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
|
||||
// rather simple, but if one util to mock a member is good enough for us, maybe
|
||||
// it's useful for matrix-js-sdk consumers in general.
|
||||
export function mockMember(member: Partial<RoomMember>): RoomMember {
|
||||
return { ...mockEmitter(), ...member } as RoomMember;
|
||||
}
|
||||
|
||||
export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
|
||||
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
|
||||
}
|
||||
|
||||
export function mockLivekitRoom(room: Partial<LivekitRoom>): LivekitRoom {
|
||||
return { ...mockEmitter(), ...room } as Partial<LivekitRoom> as LivekitRoom;
|
||||
}
|
||||
|
||||
export function mockLocalParticipant(
|
||||
participant: Partial<LocalParticipant>,
|
||||
): LocalParticipant {
|
||||
return {
|
||||
isLocal: true,
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
...mockEmitter(),
|
||||
...participant,
|
||||
} as Partial<LocalParticipant> as LocalParticipant;
|
||||
}
|
||||
|
||||
export async function withLocalMedia(
|
||||
member: Partial<RoomMember>,
|
||||
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const vm = new LocalUserMediaViewModel(
|
||||
"local",
|
||||
mockMember(member),
|
||||
mockLocalParticipant({}),
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export function mockRemoteParticipant(
|
||||
participant: Partial<RemoteParticipant>,
|
||||
): RemoteParticipant {
|
||||
return {
|
||||
isLocal: false,
|
||||
setVolume() {},
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||
...mockEmitter(),
|
||||
...participant,
|
||||
} as RemoteParticipant;
|
||||
}
|
||||
|
||||
export async function withRemoteMedia(
|
||||
member: Partial<RoomMember>,
|
||||
participant: Partial<RemoteParticipant>,
|
||||
continuation: (vm: RemoteUserMediaViewModel) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const vm = new RemoteUserMediaViewModel(
|
||||
"remote",
|
||||
mockMember(member),
|
||||
mockRemoteParticipant(participant),
|
||||
true,
|
||||
);
|
||||
try {
|
||||
await continuation(vm);
|
||||
} finally {
|
||||
vm.destroy();
|
||||
}
|
||||
}
|
||||
|
@ -4,13 +4,14 @@ Copyright 2024 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import "global-jsdom/register";
|
||||
import globalJsdom from "global-jsdom";
|
||||
import i18n from "i18next";
|
||||
import posthog from "posthog-js";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { afterEach, beforeEach } from "vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import "vitest-axe/extend-expect";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { Config } from "./config/Config";
|
||||
@ -30,12 +31,12 @@ i18n
|
||||
Config.initDefault();
|
||||
posthog.opt_out_capturing();
|
||||
|
||||
// We need to cleanup the global jsDom
|
||||
// Otherwise we will run into issues with async input test overlapping and throwing.
|
||||
afterEach(cleanup);
|
||||
|
||||
let cleanupJsDom: { (): void };
|
||||
beforeEach(() => (cleanupJsDom = globalJsdom()));
|
||||
afterEach(() => {
|
||||
cleanupJsDom();
|
||||
cleanup();
|
||||
});
|
||||
// Used by a lot of components
|
||||
window.matchMedia = global.matchMedia = (): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}) as Partial<MediaQueryList> as MediaQueryList;
|
||||
|
@ -12,11 +12,11 @@ export default defineConfig((configEnv) =>
|
||||
classNameStrategy: "non-scoped",
|
||||
},
|
||||
},
|
||||
isolate: false,
|
||||
setupFiles: ["src/vitest.setup.ts"],
|
||||
coverage: {
|
||||
reporter: ["html", "json"],
|
||||
include: ["src/"],
|
||||
exclude: ["src/**/*.{d,test}.{ts,tsx}", "src/utils/test.ts"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
21
yarn.lock
21
yarn.lock
@ -3423,7 +3423,7 @@ available-typed-arrays@^1.0.7:
|
||||
dependencies:
|
||||
possible-typed-array-names "^1.0.0"
|
||||
|
||||
axe-core@^4.10.0:
|
||||
axe-core@^4.10.0, axe-core@^4.7.2:
|
||||
version "4.10.0"
|
||||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.0.tgz#d9e56ab0147278272739a000880196cdfe113b59"
|
||||
integrity sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==
|
||||
@ -3657,6 +3657,11 @@ chalk@^4.0.0, chalk@^4.1.0:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
chalk@^5.3.0:
|
||||
version "5.3.0"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385"
|
||||
integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==
|
||||
|
||||
check-error@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc"
|
||||
@ -5820,6 +5825,11 @@ locate-path@^6.0.0:
|
||||
dependencies:
|
||||
p-locate "^5.0.0"
|
||||
|
||||
lodash-es@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
|
||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||
|
||||
lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
@ -8177,6 +8187,15 @@ vite@^5.0.0:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.3"
|
||||
|
||||
vitest-axe@^1.0.0-pre.3:
|
||||
version "1.0.0-pre.3"
|
||||
resolved "https://registry.yarnpkg.com/vitest-axe/-/vitest-axe-1.0.0-pre.3.tgz#0ea646c4ebe21c9b7ffb9ff3d6dff60b1c5a6124"
|
||||
integrity sha512-vrsyixV225vMe0vGZV0aZjOYez2Pan5MxIx2RqnYnpbbRrUN2lJpQS9ong6dfF5a7BfQenR0LOD6hei3IQIPSw==
|
||||
dependencies:
|
||||
axe-core "^4.7.2"
|
||||
chalk "^5.3.0"
|
||||
lodash-es "^4.17.21"
|
||||
|
||||
vitest@^2.0.0:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.0.5.tgz#2f15a532704a7181528e399cc5b754c7f335fd62"
|
||||
|
Loading…
Reference in New Issue
Block a user