Files
element-web/test/unit-tests/components/viewmodels/roomlist/RoomListViewModel-test.tsx
David Baker be5dd058b3 Remove old filter test (#29963)
This wasn't testing what it thought it was testing because the primary
filters aren't present at all after selecting a secondary filter that's
not compatible with them, so it was just asserting that some other filter
(which had the same index as the old one) was disabled. It breaks if
the primary filters get reordered.
2025-05-14 13:12:34 +00:00

515 lines
23 KiB
TypeScript

/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { range } from "lodash";
import { act, renderHook, waitFor } from "jest-matrix-react";
import { mocked } from "jest-mock";
import RoomListStoreV3, { LISTS_UPDATE_EVENT } from "../../../../../src/stores/room-list-v3/RoomListStoreV3";
import { mkStubRoom } from "../../../../test-utils";
import { useRoomListViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListViewModel";
import { FilterKey } from "../../../../../src/stores/room-list-v3/skip-list/filters";
import { SecondaryFilters } from "../../../../../src/components/viewmodels/roomlist/useFilteredRooms";
import { SortingAlgorithm } from "../../../../../src/stores/room-list-v3/skip-list/sorters";
import { SortOption } from "../../../../../src/components/viewmodels/roomlist/useSorter";
import SettingsStore, { type CallbackFn } from "../../../../../src/settings/SettingsStore";
import { hasCreateRoomRights, createRoom } from "../../../../../src/components/viewmodels/roomlist/utils";
import dispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { SdkContextClass } from "../../../../../src/contexts/SDKContext";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../../../../src/stores/spaces";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasCreateRoomRights: jest.fn().mockReturnValue(false),
createRoom: jest.fn(),
}));
describe("RoomListViewModel", () => {
function mockAndCreateRooms() {
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
const fn = jest
.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace")
.mockImplementation(() => [...rooms]);
return { rooms, fn };
}
afterEach(() => {
jest.restoreAllMocks();
});
it("should return a list of rooms", async () => {
const { rooms } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.rooms).toHaveLength(10);
for (const room of rooms) {
expect(vm.current.rooms).toContain(room);
}
});
it("should update list of rooms on event from room list store", async () => {
const { rooms } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
const newRoom = mkStubRoom("bar:matrix.org", "Bar", undefined);
rooms.push(newRoom);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
await waitFor(() => {
expect(vm.current.rooms).toContain(newRoom);
});
});
describe("Filters", () => {
it("should provide list of available filters", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// should have 4 filters
expect(vm.current.primaryFilters).toHaveLength(4);
// check the order
for (const [i, name] of ["Unreads", "Favourites", "People", "Rooms"].entries()) {
expect(vm.current.primaryFilters[i].name).toEqual(name);
expect(vm.current.primaryFilters[i].active).toEqual(false);
}
});
it("should get filtered rooms from RLS on toggle", () => {
const { fn } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the People toggle
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
act(() => {
vm.current.primaryFilters[i].toggle();
});
expect(fn).toHaveBeenCalledWith([FilterKey.PeopleFilter]);
expect(vm.current.primaryFilters[i].active).toEqual(true);
});
it("should change active property on toggle", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the People filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
expect(vm.current.primaryFilters[i].active).toEqual(false);
act(() => {
vm.current.primaryFilters[i].toggle();
});
expect(vm.current.primaryFilters[i].active).toEqual(true);
// Let's say that we toggle the Favourite filter
const j = vm.current.primaryFilters.findIndex((f) => f.name === "Favourites");
act(() => {
vm.current.primaryFilters[j].toggle();
});
expect(vm.current.primaryFilters[i].active).toEqual(false);
expect(vm.current.primaryFilters[j].active).toEqual(true);
});
it("should select all activity as default secondary filter", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// By default, all activity should be the active secondary filter
expect(vm.current.activeSecondaryFilter).toEqual(SecondaryFilters.AllActivity);
});
it("should be able to filter using secondary filters", () => {
const { fn } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the mentions secondary filter
act(() => {
vm.current.activateSecondaryFilter(SecondaryFilters.MentionsOnly);
});
expect(fn).toHaveBeenCalledWith([FilterKey.MentionsFilter]);
});
it("primary filters are applied on top of secondary filers", () => {
const { fn } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's say we toggle the mentions secondary filter
act(() => {
vm.current.activateSecondaryFilter(SecondaryFilters.MentionsOnly);
});
// Let's say we toggle the People filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
act(() => {
vm.current.primaryFilters[i].toggle();
});
// RLS call must include both these filters
expect(fn).toHaveBeenLastCalledWith(
expect.arrayContaining([FilterKey.PeopleFilter, FilterKey.MentionsFilter]),
);
});
it("should return the current active primary filter", async () => {
// Let's say that the user's preferred sorting is alphabetic
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Toggle people filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
expect(vm.current.primaryFilters[i].active).toEqual(false);
act(() => vm.current.primaryFilters[i].toggle());
// The active primary filter should be the People filter
expect(vm.current.activePrimaryFilter).toEqual(vm.current.primaryFilters[i]);
});
it("should remove any active primary filters when secondary filter is changed", async () => {
const { fn } = mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's first toggle the People filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
act(() => {
vm.current.primaryFilters[i].toggle();
});
expect(vm.current.primaryFilters[i].active).toEqual(true);
// Let's say we toggle the mentions secondary filter
act(() => {
vm.current.activateSecondaryFilter(SecondaryFilters.MentionsOnly);
});
// Primary filer should have been unapplied
expect(vm.current.primaryFilters[i].active).toEqual(false);
// RLS call must include only the secondary filter
expect(fn).toHaveBeenLastCalledWith(expect.arrayContaining([FilterKey.MentionsFilter]));
});
it("should remove all filters when active space is changed", async () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Let's first toggle the People filter
const i = vm.current.primaryFilters.findIndex((f) => f.name === "People");
act(() => {
vm.current.primaryFilters[i].toggle();
});
expect(vm.current.primaryFilters[i].active).toEqual(true);
// Let's say we toggle the mentions secondary filter
act(() => {
vm.current.activateSecondaryFilter(SecondaryFilters.MentionsOnly);
});
expect(vm.current.activeSecondaryFilter).toEqual(SecondaryFilters.MentionsOnly);
// Simulate a space change
await act(() => SpaceStore.instance.emit(UPDATE_SELECTED_SPACE));
// Primary filer should have been unapplied
expect(vm.current.activePrimaryFilter).toEqual(undefined);
// Secondary filter should be reset to "All Activity"
expect(vm.current.activeSecondaryFilter).toEqual(SecondaryFilters.AllActivity);
});
const testcases: Array<[string, { secondary: SecondaryFilters; filterKey: FilterKey }, string]> = [
[
"Mentions only",
{ secondary: SecondaryFilters.MentionsOnly, filterKey: FilterKey.MentionsFilter },
"Unreads",
],
[
"Invites only",
{ secondary: SecondaryFilters.InvitesOnly, filterKey: FilterKey.InvitesFilter },
"Unreads",
],
[
"Invites only",
{ secondary: SecondaryFilters.InvitesOnly, filterKey: FilterKey.InvitesFilter },
"Favourites",
],
[
"Low priority",
{ secondary: SecondaryFilters.LowPriority, filterKey: FilterKey.LowPriorityFilter },
"Favourites",
],
];
describe.each(testcases)("For secondary filter: %s", (secondaryFilterName, secondary, primaryFilterName) => {
it(`should hide incompatible primary filter: ${primaryFilterName}`, () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
// Apply the secondary filter
act(() => {
vm.current.activateSecondaryFilter(secondary.secondary);
});
// Incompatible primary filter must be hidden
expect(vm.current.primaryFilters.find((f) => f.name === primaryFilterName)).toBeUndefined();
});
});
});
describe("Sorting", () => {
it("should change sort order", () => {
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
const resort = jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(() => {});
// Change the sort option
act(() => {
vm.current.sort(SortOption.AToZ);
});
// Resort method in RLS must have been called
expect(resort).toHaveBeenCalledWith(SortingAlgorithm.Alphabetic);
});
it("should set activeSortOption based on value from settings", () => {
// Let's say that the user's preferred sorting is alphabetic
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => SortingAlgorithm.Alphabetic);
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeSortOption).toEqual(SortOption.AToZ);
});
});
describe("message preview toggle", () => {
it("should return shouldShowMessagePreview based on setting", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => true);
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.shouldShowMessagePreview).toEqual(true);
});
it("should update when setting changes", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => true);
let watchFn: CallbackFn;
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_settingname, _roomId, fn) => {
watchFn = fn;
return "";
});
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.shouldShowMessagePreview).toEqual(true);
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => false);
act(() => {
watchFn("RoomList.showMessagePreview", "", SettingLevel.DEVICE, false, false);
});
expect(vm.current.shouldShowMessagePreview).toEqual(false);
});
it("should change setting on toggle", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => true);
const fn = jest.spyOn(SettingsStore, "setValue").mockImplementation(async () => {});
mockAndCreateRooms();
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.shouldShowMessagePreview).toEqual(true);
act(() => {
vm.current.toggleMessagePreview();
});
expect(fn).toHaveBeenCalledWith("RoomList.showMessagePreview", null, "device", false);
});
});
describe("Create room and chat", () => {
it("should be canCreateRoom=false if hasCreateRoomRights=false", () => {
mocked(hasCreateRoomRights).mockReturnValue(false);
const { result } = renderHook(() => useRoomListViewModel());
expect(result.current.canCreateRoom).toBe(false);
});
it("should be canCreateRoom=true if hasCreateRoomRights=true", () => {
mocked(hasCreateRoomRights).mockReturnValue(true);
const { result } = renderHook(() => useRoomListViewModel());
expect(result.current.canCreateRoom).toBe(true);
});
it("should call createRoom", () => {
const { result } = renderHook(() => useRoomListViewModel());
result.current.createRoom();
expect(mocked(createRoom)).toHaveBeenCalled();
});
it("should dispatch Action.CreateChat", () => {
const spy = jest.spyOn(dispatcher, "fire");
const { result } = renderHook(() => useRoomListViewModel());
result.current.createChatRoom();
expect(spy).toHaveBeenCalledWith(Action.CreateChat);
});
});
describe("Sticky room and active index", () => {
function expectActiveRoom(vm: ReturnType<typeof useRoomListViewModel>, i: number, roomId: string) {
expect(vm.activeIndex).toEqual(i);
expect(vm.rooms[i].roomId).toEqual(roomId);
}
it("active index is calculated with the last opened room in a space", () => {
// Let's say there's two spaces: !space1:matrix.org and !space2:matrix.org
// Let's also say that the current active space is !space1:matrix.org
let currentSpace = "!space1:matrix.org";
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockImplementation(() => currentSpace);
const rooms = range(10).map((i) => mkStubRoom(`foo${i}:matrix.org`, `Foo ${i}`, undefined));
// Let's say all the rooms are in space1
const roomsInSpace1 = [...rooms];
// Let's say all rooms with even index are in space 2
const roomsInSpace2 = [...rooms].filter((_, i) => i % 2 === 0);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockImplementation(() =>
currentSpace === "!space1:matrix.org" ? roomsInSpace1 : roomsInSpace2,
);
// Let's say that the room at index 4 is currently active
const roomId = rooms[4].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeIndex).toEqual(4);
// Let's say that space is changed to "!space2:matrix.org"
currentSpace = "!space2:matrix.org";
// Let's say that room[6] is active in space 2
const activeRoomIdInSpace2 = rooms[6].roomId;
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockImplementation(
() => activeRoomIdInSpace2,
);
act(() => {
RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT);
});
// Active index should be 3 even without the room change event.
expectActiveRoom(vm.current, 3, activeRoomIdInSpace2);
});
it("active room and active index are retained on order change", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeIndex).toEqual(5);
// Let's say that room at index 9 moves to index 5
const room9 = rooms[9];
rooms.splice(9, 1);
rooms.splice(5, 0, room9);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
// Active room index should still be 5
expectActiveRoom(vm.current, 5, roomId);
// Let's add 2 new rooms from index 0
const newRoom1 = mkStubRoom("bar1:matrix.org", "Bar 1", undefined);
const newRoom2 = mkStubRoom("bar2:matrix.org", "Bar 2", undefined);
rooms.unshift(newRoom1, newRoom2);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
// Active room index should still be 5
expectActiveRoom(vm.current, 5, roomId);
});
it("active room and active index are updated when another room is opened", () => {
const { rooms } = mockAndCreateRooms();
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's say that room at index 9 becomes active
const room = rooms[9];
act(() => {
dispatcher.dispatch(
{
action: Action.ActiveRoomChanged,
oldRoomId: null,
newRoomId: room.roomId,
},
true,
);
});
// Active room index should change to reflect new room
expectActiveRoom(vm.current, 9, room.roomId);
});
it("active room and active index are updated when active index spills out of rooms array bounds", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's say that we remove rooms from the start of the array
for (let i = 0; i < 4; ++i) {
// We should be able to do 4 deletions before we run out of rooms
rooms.splice(0, 1);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expectActiveRoom(vm.current, 5, roomId);
}
// If we remove one more room from the start, there's not going to be enough rooms
// to maintain the active index.
rooms.splice(0, 1);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expectActiveRoom(vm.current, 0, roomId);
});
it("active room and active index are retained when rooms that appear after the active room are deleted", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
const roomId = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's say that we remove rooms from the start of the array
for (let i = 0; i < 4; ++i) {
// Deleting rooms after index 5 (active) should not update the active index
rooms.splice(6, 1);
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expectActiveRoom(vm.current, 5, roomId);
}
});
it("active room index becomes undefined when active room is deleted", () => {
const { rooms } = mockAndCreateRooms();
// Let's say that the room at index 5 is active
let roomId: string | undefined = rooms[5].roomId;
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => roomId);
const { result: vm } = renderHook(() => useRoomListViewModel());
expectActiveRoom(vm.current, 5, roomId);
// Let's remove the active room (i.e room at index 5)
rooms.splice(5, 1);
roomId = undefined;
act(() => RoomListStoreV3.instance.emit(LISTS_UPDATE_EVENT));
expect(vm.current.activeIndex).toBeUndefined();
});
it("active room index is initially undefined", () => {
mockAndCreateRooms();
// Let's say that there's no active room currently
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockImplementation(() => undefined);
const { result: vm } = renderHook(() => useRoomListViewModel());
expect(vm.current.activeIndex).toEqual(undefined);
});
});
});