Files
letro-ios/UnitTests/Sources/TimelineViewModelTests.swift
Copilot 4834f453ef Finish migration of UnitTests target from XCTestCase to Swift Testing (#5129)
* Initial plan

* Migrate 3 test files from XCTest to Swift Testing

- MediaUploadPreviewScreenViewModelTests: @MainActor @Suite struct with init(),
  BundleFinder class for Bundle(for:), mutating test/setup functions,
  [self] capture replacing [weak self] in closures
- NotificationManagerTests: @MainActor @Suite final class with init()/deinit,
  expectation/fulfillment(of:) replaced with confirmation(...), test_ prefix stripped
- NotificationSettingsScreenViewModelTests: @MainActor @Suite struct with
  init() throws, non-optional stored properties, test prefix stripped

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 3 XCTest files to Swift Testing

- NotificationSettingsEditScreenViewModelTests: @MainActor @Suite struct with init() throws, mutating test methods
- TimelineViewModelTests: @MainActor @Suite final class with init() async throws + deinit
- AttributedStringBuilderTests: @Suite struct with init() async throws

All XCT assertions replaced with #expect/#require/Issue.record

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 4 test files from XCTest to Swift Testing

- TimelineMediaPreviewViewModelTests: @Suite struct, mutating @Test funcs,
  testLoadingItem renamed to loadingItem (called internally by other tests)
- ServerConfirmationScreenViewModelTests: @Suite final class with init()/deinit
- CompletionSuggestionServiceTests: @Suite struct with init()
- RoomFlowCoordinatorTests: @Suite final class with deinit

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 4 test files from XCTest to Swift Testing

- VoiceMessageRecorderTests: @Suite struct with init() async throws,
  added BundleFinder class for Bundle lookup, migrated all assertions
- SpaceScreenViewModelTests: @Suite struct, private mutating setupViewModel,
  all test funcs mutating, XCTestExpectation → confirmation
- RoomNotificationSettingsScreenViewModelTests: @Suite struct with
  init() throws, cancellable tests marked mutating
- JoinRoomScreenViewModelTests: @Suite final class with init()/deinit,
  XCTestExpectation → confirmation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate 6 test files from XCTestCase to Swift Testing

Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>

* Fix trailing blank line in RoomPollsHistoryScreenViewModelTests

Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>

* Migrate 3 test files from XCTest to Swift Testing

- MediaUploadingPreprocessorTests: @Suite final class with init()/deinit,
  removed executionTimeAllowance, XCTAssertEqual(accuracy:) → abs(Double)
- SecurityAndPrivacyScreenViewModelTests: @MainActor @Suite final class,
  5 expectation+fulfillment → await confirmation(...)
- CreateRoomViewModelTests: @MainActor @Suite final class,
  4 expectation+fulfillment → await confirmation(...)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate RoomScreenViewModelTests and RoomDetailsScreenViewModelTests to Swift Testing

- Replace XCTest with Testing framework
- RoomScreenViewModelTests: final class with init() async throws + deinit
- RoomDetailsScreenViewModelTests: struct with init() and mutating funcs
- Convert XCT assertions to #expect / Issue.record
- Convert XCTestExpectation patterns to confirmation { confirm in }
- Strip 'test' prefix from all test function names

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Migrate ComposerToolbarViewModelTests from XCTest to Swift Testing

- Replace import XCTest with import Testing
- Convert XCTestCase class to @MainActor @Suite final class
- Replace setUp()/tearDown() with init()/deinit
- Strip 'test' prefix from all 41 test method names and add @Test
- Replace XCTAssert* with #expect()/#require()
- Replace try XCTUnwrap() with try #require()
- Convert expectation+wait patterns to deferFulfillment with PassthroughSubject
- Convert isInverted expectation to boolean flag checked after await
- Use deferFulfillment on $viewState for state-transition tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address comments with Copilot.

* Fix the failing tests.

* Fixed flaky tests (#5137)

resolved flaky tests

* Tweaks and fixes.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: pixlwave <6060466+pixlwave@users.noreply.github.com>
Co-authored-by: Doug <douglase@element.io>
Co-authored-by: Mauro <34335419+Velin92@users.noreply.github.com>
2026-02-24 12:20:01 +00:00

671 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
@Suite
@MainActor
final class TimelineViewModelTests {
var userIndicatorControllerMock: UserIndicatorControllerMock!
var cancellables = Set<AnyCancellable>()
init() async throws {
AppSettings.resetAllSettings()
cancellables.removeAll()
userIndicatorControllerMock = UserIndicatorControllerMock.default
}
deinit {
userIndicatorControllerMock = nil
}
// 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 viewModel = TimelineViewModel(roomProxy: roomProxy,
timelineController: timelineController,
userSession: UserSessionMock(.init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
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
// 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,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
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 viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "",
members: [RoomMemberProxyMock.mockAdmin,
RoomMemberProxyMock.mockAlice],
ownUserID: RoomMemberProxyMock.mockAdmin.userID)),
timelineController: MockTimelineController(),
userSession: UserSessionMock(.init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
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 viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "",
members: [RoomMemberProxyMock.mockAdmin,
RoomMemberProxyMock.mockAlice],
ownUserID: RoomMemberProxyMock.mockAlice.userID)),
timelineController: MockTimelineController(),
userSession: UserSessionMock(.init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
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 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,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
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 {
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,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
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 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,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
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 {
TimelineViewModel(roomProxy: roomProxy ?? JoinedRoomProxyMock(.init(name: "")),
focussedEventID: focussedEventID,
timelineController: timelineController,
userSession: UserSessionMock(.init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
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")
}
}