mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
ff5dcb040d
Removes the ServiceLocator singleton and instead directly injects the dependencies it was holding for more control over instantiation of the various component subtrees and better visibility over how they're being used.
680 lines
36 KiB
Swift
680 lines
36 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Combine
|
|
@testable import ElementX
|
|
import Foundation
|
|
import MatrixRustSDK
|
|
import Testing
|
|
|
|
@MainActor
|
|
final class TimelineViewModelTests {
|
|
var cancellables = Set<AnyCancellable>()
|
|
|
|
init() async throws {
|
|
AppSettings.resetAllSettings()
|
|
cancellables.removeAll()
|
|
}
|
|
|
|
// MARK: - Message Grouping
|
|
|
|
@Test
|
|
func messageGrouping() {
|
|
// Given 3 messages from Bob.
|
|
let items = [
|
|
TextRoomTimelineItem(text: "Message 1",
|
|
sender: "bob"),
|
|
TextRoomTimelineItem(text: "Message 2",
|
|
sender: "bob"),
|
|
TextRoomTimelineItem(text: "Message 3",
|
|
sender: "bob")
|
|
]
|
|
|
|
// When showing them in a timeline.
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
|
|
// Then the messages should be grouped together.
|
|
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .first, "Nothing should prevent the first message from being grouped.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .middle, "Nothing should prevent the middle message from being grouped.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .last, "Nothing should prevent the last message from being grouped.")
|
|
}
|
|
|
|
@Test
|
|
func messageGroupingMultipleSenders() {
|
|
// Given some interleaved messages from Bob and Alice.
|
|
let items = [
|
|
TextRoomTimelineItem(text: "Message 1",
|
|
sender: "alice"),
|
|
TextRoomTimelineItem(text: "Message 2",
|
|
sender: "bob"),
|
|
TextRoomTimelineItem(text: "Message 3",
|
|
sender: "alice"),
|
|
TextRoomTimelineItem(text: "Message 4",
|
|
sender: "alice"),
|
|
TextRoomTimelineItem(text: "Message 5",
|
|
sender: "bob"),
|
|
TextRoomTimelineItem(text: "Message 6",
|
|
sender: "bob")
|
|
]
|
|
|
|
// When showing them in a timeline.
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
|
|
// Then the messages should be grouped by sender.
|
|
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .single, "A message should not be grouped when the sender changes.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .single, "A message should not be grouped when the sender changes.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .first, "A group should start with a new sender if there are more messages from that sender.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[3].groupStyle == .last, "A group should be ended when the sender changes in the next message.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[4].groupStyle == .first, "A group should start with a new sender if there are more messages from that sender.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[5].groupStyle == .last, "A group should be ended when the sender changes in the next message.")
|
|
}
|
|
|
|
@Test
|
|
func messageGroupingWithLeadingReactions() {
|
|
// Given 3 messages from Bob where the first message has a reaction.
|
|
let items = [
|
|
TextRoomTimelineItem(text: "Message 1",
|
|
sender: "bob",
|
|
addReactions: true),
|
|
TextRoomTimelineItem(text: "Message 2",
|
|
sender: "bob"),
|
|
TextRoomTimelineItem(text: "Message 3",
|
|
sender: "bob")
|
|
]
|
|
|
|
// When showing them in a timeline.
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
|
|
// Then the first message should not be grouped but the other two should.
|
|
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .single, "When the first message has reactions it should not be grouped.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .first, "A new group should be made when the preceding message has reactions.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .last, "Nothing should prevent the last message from being grouped.")
|
|
}
|
|
|
|
@Test
|
|
func messageGroupingWithInnerReactions() {
|
|
// Given 3 messages from Bob where the middle message has a reaction.
|
|
let items = [
|
|
TextRoomTimelineItem(text: "Message 1",
|
|
sender: "bob"),
|
|
TextRoomTimelineItem(text: "Message 2",
|
|
sender: "bob",
|
|
addReactions: true),
|
|
TextRoomTimelineItem(text: "Message 3",
|
|
sender: "bob")
|
|
]
|
|
|
|
// When showing them in a timeline.
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
|
|
// Then the first and second messages should be grouped and the last one should not.
|
|
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .first, "Nothing should prevent the first message from being grouped.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .last, "When the message has reactions, the group should end here.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .single, "The last message should not be grouped when the preceding message has reactions.")
|
|
}
|
|
|
|
@Test
|
|
func messageGroupingWithTrailingReactions() {
|
|
// Given 3 messages from Bob where the last message has a reaction.
|
|
let items = [
|
|
TextRoomTimelineItem(text: "Message 1",
|
|
sender: "bob"),
|
|
TextRoomTimelineItem(text: "Message 2",
|
|
sender: "bob"),
|
|
TextRoomTimelineItem(text: "Message 3",
|
|
sender: "bob",
|
|
addReactions: true)
|
|
]
|
|
|
|
// When showing them in a timeline.
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
|
|
// Then the messages should be grouped together.
|
|
#expect(viewModel.state.timelineState.itemViewStates[0].groupStyle == .first, "Nothing should prevent the first message from being grouped.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[1].groupStyle == .middle, "Nothing should prevent the second message from being grouped.")
|
|
#expect(viewModel.state.timelineState.itemViewStates[2].groupStyle == .last, "Reactions on the last message should not prevent it from being grouped.")
|
|
}
|
|
|
|
// MARK: - Focussing
|
|
|
|
@Test
|
|
func focusItem() async throws {
|
|
// Given a room with 3 items loaded in a live timeline.
|
|
let items = [TextRoomTimelineItem(eventID: "t1"),
|
|
TextRoomTimelineItem(eventID: "t2"),
|
|
TextRoomTimelineItem(eventID: "t3")]
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
#expect(timelineController.focusOnEventCallCount == 0)
|
|
#expect(viewModel.context.viewState.timelineState.isLive)
|
|
#expect(viewModel.context.viewState.timelineState.focussedEvent == nil)
|
|
|
|
// When focussing on an item that isn't loaded.
|
|
let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineState.isLive }
|
|
await viewModel.focusOnEvent(eventID: "t4")
|
|
try await deferred.fulfill()
|
|
|
|
// Then a new timeline should be loaded and the room focussed on that event.
|
|
#expect(timelineController.focusOnEventCallCount == 1)
|
|
#expect(!viewModel.context.viewState.timelineState.isLive)
|
|
#expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t4", appearance: .immediate))
|
|
}
|
|
|
|
@Test
|
|
func focusLoadedItem() async throws {
|
|
// Given a room with 3 items loaded in a live timeline.
|
|
let items = [TextRoomTimelineItem(eventID: "t1"),
|
|
TextRoomTimelineItem(eventID: "t2"),
|
|
TextRoomTimelineItem(eventID: "t3")]
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
#expect(timelineController.focusOnEventCallCount == 0)
|
|
#expect(viewModel.context.viewState.timelineState.isLive)
|
|
#expect(viewModel.context.viewState.timelineState.focussedEvent == nil)
|
|
|
|
// When focussing on a loaded item.
|
|
let deferred = deferFailure(viewModel.context.$viewState, timeout: .seconds(1)) { !$0.timelineState.isLive }
|
|
await viewModel.focusOnEvent(eventID: "t1")
|
|
try await deferred.fulfill()
|
|
|
|
// Then the timeline should remain live and the item should be focussed.
|
|
#expect(timelineController.focusOnEventCallCount == 0)
|
|
#expect(viewModel.context.viewState.timelineState.isLive)
|
|
#expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t1", appearance: .animated))
|
|
}
|
|
|
|
@Test
|
|
func focusLive() async throws {
|
|
// Given a room with a non-live timeline focussed on a particular event.
|
|
let items = [TextRoomTimelineItem(eventID: "t1"),
|
|
TextRoomTimelineItem(eventID: "t2"),
|
|
TextRoomTimelineItem(eventID: "t3")]
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
|
|
var deferred = deferFulfillment(viewModel.context.$viewState) { !$0.timelineState.isLive }
|
|
await viewModel.focusOnEvent(eventID: "t4")
|
|
try await deferred.fulfill()
|
|
|
|
#expect(timelineController.focusLiveCallCount == 0)
|
|
#expect(!viewModel.context.viewState.timelineState.isLive)
|
|
#expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t4", appearance: .immediate))
|
|
|
|
// When switching back to a live timeline.
|
|
deferred = deferFulfillment(viewModel.context.$viewState) { $0.timelineState.isLive }
|
|
viewModel.context.send(viewAction: .focusLive)
|
|
try await deferred.fulfill()
|
|
|
|
// Then the timeline should switch back to being live and the event focus should be removed.
|
|
#expect(timelineController.focusLiveCallCount == 1)
|
|
#expect(viewModel.context.viewState.timelineState.isLive)
|
|
#expect(viewModel.context.viewState.timelineState.focussedEvent == nil)
|
|
}
|
|
|
|
@Test
|
|
func initialFocusViewState() {
|
|
let timelineController = MockTimelineController()
|
|
|
|
let viewModel = makeViewModel(focussedEventID: "t10", timelineController: timelineController)
|
|
#expect(viewModel.context.viewState.timelineState.focussedEvent == .init(eventID: "t10", appearance: .immediate))
|
|
}
|
|
|
|
// MARK: - Read Receipts
|
|
|
|
@Test
|
|
func sendReadReceipt() async throws {
|
|
// Given a room with only text items in the timeline
|
|
let items = [TextRoomTimelineItem(eventID: "t1"),
|
|
TextRoomTimelineItem(eventID: "t2"),
|
|
TextRoomTimelineItem(eventID: "t3")]
|
|
let (viewModel, _, timelineProxy, _) = readReceiptsConfiguration(with: items)
|
|
|
|
// When sending a read receipt for the last item.
|
|
try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(#require(items.last?.id)))
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Then the receipt should be sent.
|
|
#expect(timelineProxy.sendReadReceiptForTypeCalled == true)
|
|
let arguments = timelineProxy.sendReadReceiptForTypeReceivedArguments
|
|
#expect(arguments?.eventID == "t3")
|
|
#expect(arguments?.type == .read)
|
|
}
|
|
|
|
@Test
|
|
func sendReadReceiptWithoutEvents() async throws {
|
|
// Given a room with only virtual items.
|
|
let items = [SeparatorRoomTimelineItem(uniqueID: .init("v1")),
|
|
SeparatorRoomTimelineItem(uniqueID: .init("v2")),
|
|
SeparatorRoomTimelineItem(uniqueID: .init("v3"))]
|
|
let (viewModel, _, timelineProxy, _) = readReceiptsConfiguration(with: items)
|
|
|
|
// When sending a read receipt for the last item.
|
|
try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(#require(items.last?.id)))
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
// Then nothing should be sent.
|
|
#expect(timelineProxy.sendReadReceiptForTypeCalled == false)
|
|
}
|
|
|
|
@Test
|
|
func sendReadReceiptVirtualLast() async throws {
|
|
// Given a room where the last event is a virtual item.
|
|
let items: [RoomTimelineItemProtocol] = [TextRoomTimelineItem(eventID: "t1"),
|
|
TextRoomTimelineItem(eventID: "t2"),
|
|
SeparatorRoomTimelineItem(uniqueID: .init("v3"))]
|
|
let (viewModel, _, _, _) = readReceiptsConfiguration(with: items)
|
|
|
|
// When sending a read receipt for the last item.
|
|
try viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(#require(items.last?.id)))
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
}
|
|
|
|
// swiftlint:disable:next large_tuple
|
|
private func readReceiptsConfiguration(with items: [RoomTimelineItemProtocol]) -> (TimelineViewModel,
|
|
JoinedRoomProxyMock,
|
|
TimelineProxyMock,
|
|
MockTimelineController) {
|
|
let roomProxy = JoinedRoomProxyMock(.init(name: ""))
|
|
|
|
let timelineProxy = TimelineProxyMock()
|
|
|
|
roomProxy.timeline = timelineProxy
|
|
let timelineController = MockTimelineController()
|
|
|
|
timelineProxy.sendReadReceiptForTypeReturnValue = .success(())
|
|
|
|
timelineController.timelineItems = items
|
|
timelineController.roomProxy = roomProxy
|
|
|
|
let appSettings = AppSettings()
|
|
|
|
let viewModel = TimelineViewModel(roomProxy: roomProxy,
|
|
timelineController: timelineController,
|
|
userSession: UserSessionMock(.init()),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appMediator: AppMediatorMock.default,
|
|
appSettings: appSettings,
|
|
analyticsService: .mock(settings: appSettings),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
return (viewModel, roomProxy, timelineProxy, timelineController)
|
|
}
|
|
|
|
@Test
|
|
func showReadReceipts() async throws {
|
|
let receipts: [ReadReceipt] = [.init(userID: "@alice:matrix.org", formattedTimestamp: "12:00"),
|
|
.init(userID: "@charlie:matrix.org", formattedTimestamp: "11:00")]
|
|
// Given 3 messages from Bob where the middle message has a reaction.
|
|
let message = TextRoomTimelineItem(text: "Test",
|
|
sender: "bob",
|
|
addReadReceipts: receipts)
|
|
let id = message.id
|
|
|
|
let appSettings = AppSettings()
|
|
|
|
// When showing them in a timeline.
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = [message]
|
|
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "", members: [RoomMemberProxyMock.mockAlice, RoomMemberProxyMock.mockCharlie])),
|
|
timelineController: timelineController,
|
|
userSession: UserSessionMock(.init()),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appMediator: AppMediatorMock.default,
|
|
appSettings: appSettings,
|
|
analyticsService: .mock(settings: appSettings),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
|
|
let deferred = deferFulfillment(viewModel.context.$viewState) { value in
|
|
value.bindings.readReceiptsSummaryInfo?.orderedReceipts == receipts
|
|
}
|
|
|
|
viewModel.context.send(viewAction: .displayReadReceipts(itemID: id))
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
@Test
|
|
func showManageUserAsAdmin() async throws {
|
|
let appSettings = AppSettings()
|
|
|
|
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "",
|
|
members: [RoomMemberProxyMock.mockAdmin,
|
|
RoomMemberProxyMock.mockAlice],
|
|
ownUserID: RoomMemberProxyMock.mockAdmin.userID)),
|
|
timelineController: MockTimelineController(),
|
|
userSession: UserSessionMock(.init()),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appMediator: AppMediatorMock.default,
|
|
appSettings: appSettings,
|
|
analyticsService: .mock(settings: appSettings),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
|
|
var deferred = deferFulfillment(viewModel.context.$viewState) { value in
|
|
value.canCurrentUserKick && value.canCurrentUserBan
|
|
}
|
|
|
|
try await deferred.fulfill()
|
|
|
|
deferred = deferFulfillment(viewModel.context.$viewState) { value in
|
|
value.bindings.manageMemberViewModel != nil
|
|
}
|
|
|
|
viewModel.context.send(viewAction: .tappedOnSenderDetails(sender: .init(with: RoomMemberProxyMock.mockAlice)))
|
|
try await deferred.fulfill()
|
|
|
|
#expect(viewModel.context.manageMemberViewModel?.id == RoomMemberProxyMock.mockAlice.userID)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canBan == true)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canKick == true)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.isKickDisabled == false)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled == false)
|
|
}
|
|
|
|
@Test
|
|
func showDetailsForAnAdmin() async throws {
|
|
let appSettings = AppSettings()
|
|
|
|
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "",
|
|
members: [RoomMemberProxyMock.mockAdmin,
|
|
RoomMemberProxyMock.mockAlice],
|
|
ownUserID: RoomMemberProxyMock.mockAlice.userID)),
|
|
timelineController: MockTimelineController(),
|
|
userSession: UserSessionMock(.init()),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appMediator: AppMediatorMock.default,
|
|
appSettings: appSettings,
|
|
analyticsService: .mock(settings: appSettings),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
|
|
var deferredState = deferFulfillment(viewModel.context.$viewState) { value in
|
|
!value.canCurrentUserKick && !value.canCurrentUserBan
|
|
}
|
|
|
|
try await deferredState.fulfill()
|
|
|
|
deferredState = deferFulfillment(viewModel.context.$viewState) { value in
|
|
value.bindings.manageMemberViewModel != nil
|
|
}
|
|
|
|
viewModel.context.send(viewAction: .tappedOnSenderDetails(sender: .init(with: RoomMemberProxyMock.mockAdmin)))
|
|
try await deferredState.fulfill()
|
|
|
|
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canBan == false)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canKick == false)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.isKickDisabled == true)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled == true)
|
|
#expect(viewModel.context.manageMemberViewModel?.id == RoomMemberProxyMock.mockAdmin.userID)
|
|
}
|
|
|
|
@Test
|
|
func showDetailsForABannedUser() async throws {
|
|
let appSettings = AppSettings()
|
|
|
|
let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "",
|
|
members: [RoomMemberProxyMock.mockAdmin,
|
|
RoomMemberProxyMock.mockBanned[0]],
|
|
ownUserID: RoomMemberProxyMock.mockAdmin.userID)),
|
|
timelineController: MockTimelineController(),
|
|
userSession: UserSessionMock(.init()),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appMediator: AppMediatorMock.default,
|
|
appSettings: appSettings,
|
|
analyticsService: .mock(settings: appSettings),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
|
|
var deferredState = deferFulfillment(viewModel.context.$viewState) { value in
|
|
value.canCurrentUserKick && value.canCurrentUserBan
|
|
}
|
|
|
|
try await deferredState.fulfill()
|
|
|
|
deferredState = deferFulfillment(viewModel.context.$viewState) { value in
|
|
value.bindings.manageMemberViewModel != nil
|
|
}
|
|
|
|
viewModel.context.send(viewAction: .tappedOnSenderDetails(sender: .init(with: RoomMemberProxyMock.mockBanned[0])))
|
|
try await deferredState.fulfill()
|
|
|
|
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canBan == true)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.permissions.canKick == true)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.isKickDisabled == true)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.isBanUnbanDisabled == false)
|
|
#expect(viewModel.context.manageMemberViewModel?.state.isMemberBanned == true)
|
|
#expect(viewModel.context.manageMemberViewModel?.id == RoomMemberProxyMock.mockBanned[0].userID)
|
|
}
|
|
|
|
// MARK: - Pins
|
|
|
|
@Test
|
|
func pinnedEvents() async throws {
|
|
let appSettings = AppSettings()
|
|
|
|
var configuration = JoinedRoomProxyMockConfiguration(name: "",
|
|
pinnedEventIDs: .init(["test1"]))
|
|
let roomProxyMock = JoinedRoomProxyMock(configuration)
|
|
let infoSubject = CurrentValueSubject<RoomInfoProxyProtocol, Never>(RoomInfoProxyMock(configuration))
|
|
roomProxyMock.underlyingInfoPublisher = infoSubject.asCurrentValuePublisher()
|
|
|
|
let viewModel = TimelineViewModel(roomProxy: roomProxyMock,
|
|
timelineController: MockTimelineController(),
|
|
userSession: UserSessionMock(.init()),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appMediator: AppMediatorMock.default,
|
|
appSettings: appSettings,
|
|
analyticsService: .mock(settings: appSettings),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
#expect(configuration.pinnedEventIDs == viewModel.context.viewState.pinnedEventIDs)
|
|
|
|
configuration.pinnedEventIDs = ["test1", "test2"]
|
|
let deferred = deferFulfillment(viewModel.context.$viewState) { value in
|
|
value.pinnedEventIDs == ["test1", "test2"]
|
|
}
|
|
infoSubject.send(RoomInfoProxyMock(configuration))
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
@Test
|
|
func canUserPinEvents() async throws {
|
|
let appSettings = AppSettings()
|
|
|
|
let configuration = JoinedRoomProxyMockConfiguration(name: "",
|
|
powerLevelsConfiguration: .init(canUserPin: true))
|
|
let roomProxyMock = JoinedRoomProxyMock(configuration)
|
|
let infoSubject = CurrentValueSubject<RoomInfoProxyProtocol, Never>(RoomInfoProxyMock(configuration))
|
|
roomProxyMock.underlyingInfoPublisher = infoSubject.asCurrentValuePublisher()
|
|
|
|
let viewModel = TimelineViewModel(roomProxy: roomProxyMock,
|
|
timelineController: MockTimelineController(),
|
|
userSession: UserSessionMock(.init()),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appMediator: AppMediatorMock.default,
|
|
appSettings: appSettings,
|
|
analyticsService: .mock(settings: appSettings),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
|
|
var deferred = deferFulfillment(viewModel.context.$viewState) { value in
|
|
value.canCurrentUserPin
|
|
}
|
|
try await deferred.fulfill()
|
|
|
|
let powerLevelsProxyMock = RoomPowerLevelsProxyMock(configuration: .init())
|
|
powerLevelsProxyMock.canUserPinOrUnpinUserIDReturnValue = .success(false)
|
|
powerLevelsProxyMock.canOwnUserPinOrUnpinReturnValue = false
|
|
roomProxyMock.powerLevelsReturnValue = .success(powerLevelsProxyMock)
|
|
|
|
let roomInfoProxyMock = RoomInfoProxyMock(configuration)
|
|
roomInfoProxyMock.powerLevels = powerLevelsProxyMock
|
|
|
|
deferred = deferFulfillment(viewModel.context.$viewState) { value in
|
|
!value.canCurrentUserPin
|
|
}
|
|
infoSubject.send(roomInfoProxyMock)
|
|
try await deferred.fulfill()
|
|
}
|
|
|
|
// MARK: - Tap Actions
|
|
|
|
@Test
|
|
func tapSendInfoEncryptionAuthentictyDisplaysAlert() {
|
|
// Given a room with an event whose authenticity could not be verified
|
|
let items = [TextRoomTimelineItem(eventID: "t1", encryptionAuthenticity: .verificationViolation(color: .red))]
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
|
|
#expect(viewModel.state.bindings.alertInfo == nil)
|
|
|
|
viewModel.process(viewAction: .itemSendInfoTapped(itemID: items[0].id))
|
|
|
|
#expect(viewModel.state.bindings.alertInfo?.title == "Encrypted by a previously-verified user.")
|
|
}
|
|
|
|
@Test
|
|
func tapSendInfoEncryptionForwarderDisplaysAlert() {
|
|
// Given a room with an event whose key was forwarded
|
|
let items = [TextRoomTimelineItem(eventID: "t1", keyForwarder: .test)]
|
|
let timelineController = MockTimelineController()
|
|
timelineController.timelineItems = items
|
|
let viewModel = makeViewModel(timelineController: timelineController)
|
|
|
|
#expect(viewModel.state.bindings.alertInfo == nil)
|
|
|
|
viewModel.process(viewAction: .itemSendInfoTapped(itemID: items[0].id))
|
|
|
|
#expect(viewModel.state.bindings.alertInfo?.title == "alice (@alice:matrix.org) shared this message since you were not in the room when it was sent.")
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func makeViewModel(roomProxy: JoinedRoomProxyProtocol? = nil,
|
|
focussedEventID: String? = nil,
|
|
timelineController: TimelineControllerProtocol) -> TimelineViewModel {
|
|
let appSettings = AppSettings()
|
|
|
|
return TimelineViewModel(roomProxy: roomProxy ?? JoinedRoomProxyMock(.init(name: "")),
|
|
focussedEventID: focussedEventID,
|
|
timelineController: timelineController,
|
|
userSession: UserSessionMock(.init()),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appMediator: AppMediatorMock.default,
|
|
appSettings: appSettings,
|
|
analyticsService: .mock(settings: appSettings),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
}
|
|
}
|
|
|
|
private extension TextRoomTimelineItem {
|
|
init(text: String, sender: String, addReactions: Bool = false, addReadReceipts: [ReadReceipt] = []) {
|
|
let reactions = addReactions ? [AggregatedReaction(accountOwnerID: "bob", key: "🦄", senders: [ReactionSender(id: sender, timestamp: Date())])] : []
|
|
self.init(id: .randomEvent,
|
|
timestamp: .mock,
|
|
isOutgoing: sender == "bob",
|
|
isEditable: sender == "bob",
|
|
canBeRepliedTo: true,
|
|
sender: .init(id: "@\(sender):server.com", displayName: sender),
|
|
content: .init(body: text),
|
|
properties: RoomTimelineItemProperties(reactions: reactions, orderedReadReceipts: addReadReceipts))
|
|
}
|
|
}
|
|
|
|
private extension SeparatorRoomTimelineItem {
|
|
init(uniqueID: TimelineItemIdentifier.UniqueID) {
|
|
self.init(id: .virtual(uniqueID: uniqueID), timestamp: .mock)
|
|
}
|
|
}
|
|
|
|
private extension TextRoomTimelineItem {
|
|
init(eventID: String) {
|
|
self.init(id: .event(uniqueID: .init(UUID().uuidString), eventOrTransactionID: .eventID(eventID)),
|
|
timestamp: .mock,
|
|
isOutgoing: false,
|
|
isEditable: false,
|
|
canBeRepliedTo: true,
|
|
sender: .init(id: ""),
|
|
content: .init(body: "Hello, World!"))
|
|
}
|
|
}
|
|
|
|
private extension TextRoomTimelineItem {
|
|
init(eventID: String, keyForwarder: TimelineItemKeyForwarder) {
|
|
self.init(id: .event(uniqueID: .init(UUID().uuidString), eventOrTransactionID: .eventID(eventID)),
|
|
timestamp: .mock,
|
|
isOutgoing: false,
|
|
isEditable: false,
|
|
canBeRepliedTo: true,
|
|
sender: .init(id: ""),
|
|
content: .init(body: "Hello, World!"),
|
|
properties: RoomTimelineItemProperties(encryptionForwarder: keyForwarder))
|
|
}
|
|
}
|
|
|
|
private extension TextRoomTimelineItem {
|
|
init(eventID: String, encryptionAuthenticity: EncryptionAuthenticity) {
|
|
self.init(id: .event(uniqueID: .init(UUID().uuidString), eventOrTransactionID: .eventID(eventID)),
|
|
timestamp: .mock,
|
|
isOutgoing: false,
|
|
isEditable: false,
|
|
canBeRepliedTo: true,
|
|
sender: .init(id: ""),
|
|
content: .init(body: "Hello, World!"),
|
|
properties: RoomTimelineItemProperties(encryptionAuthenticity: encryptionAuthenticity))
|
|
}
|
|
}
|
|
|
|
private extension TimelineItemSender {
|
|
init(with proxy: RoomMemberProxyMock) {
|
|
self.init(id: proxy.userID,
|
|
displayName: proxy.displayName ?? "",
|
|
isDisplayNameAmbiguous: false,
|
|
avatarURL: proxy.avatarURL)
|
|
}
|
|
}
|
|
|
|
private extension TimelineItemKeyForwarder {
|
|
static var test: TimelineItemKeyForwarder {
|
|
TimelineItemKeyForwarder(id: "@alice:matrix.org", displayName: "alice")
|
|
}
|
|
}
|