Move MockTimelineController signalling to a new SignallingTimelineItemProviderMock

This allows for testing more of a flows and using a real TimelineController in
a lot of the UI tests
This commit is contained in:
Stefan Ceriu
2026-05-28 18:44:03 +03:00
parent bb2cbb6592
commit 4830424427
5 changed files with 219 additions and 104 deletions
@@ -0,0 +1,118 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
// periphery:ignore:all
import Combine
import Foundation
import MatrixRustSDK
/// A ``TimelineItemProviderMock`` subclass that optionally listens for ``UITestsSignalling`` signals
/// to drive pagination and incoming message simulation.
@MainActor
class SignallingTimelineItemProviderMock: TimelineItemProviderMock {
private let stateSubject: CurrentValueSubject<([TimelineItemProxy], TimelinePaginationState), Never>
/// An array of item arrays that will be prepended in order on each paginate signal.
var backPaginationResponses: [[TimelineItemProxy]] = []
/// An array of items that will be appended in order on each incoming message signal.
var incomingItems: [TimelineItemProxy] = []
private var client: UITestsSignalling.Client?
private var signalCancellable: AnyCancellable?
init(itemProxies: [TimelineItemProxy] = [],
paginationState: TimelinePaginationState = .init(backward: .idle, forward: .endReached),
listenForSignals: Bool = false) {
stateSubject = CurrentValueSubject((itemProxies, paginationState))
super.init()
self.itemProxies = itemProxies
underlyingPaginationState = paginationState
underlyingKind = .live
underlyingUpdatePublisher = stateSubject.eraseToAnyPublisher()
underlyingMembershipChangePublisher = Empty().eraseToAnyPublisher()
guard listenForSignals else { return }
do {
let client = try UITestsSignalling.Client(mode: .app)
signalCancellable = client.signals.sink { [weak self] signal in
Task { @MainActor [weak self] in
guard let self else { return }
do {
try self.handleSignal(signal)
} catch {
MXLog.error(error.localizedDescription)
}
}
}
self.client = client
} catch {
fatalError("Failure setting up signalling: \(error)")
}
}
// MARK: - Private
private func handleSignal(_ signal: UITestsSignal) throws {
switch signal {
case .timeline(.paginate):
try simulateBackPagination()
case .timeline(.incomingMessage):
try simulateIncomingItem()
default:
break
}
}
private func simulateIncomingItem() throws {
guard !incomingItems.isEmpty else { return }
let item = incomingItems.removeFirst()
let (items, paginationState) = stateSubject.value
let newItems = items + [item]
itemProxies = newItems
stateSubject.send((newItems, paginationState))
try client?.send(.success)
}
func inject(_ proxies: [TimelineItemProxy]) {
let (items, paginationState) = stateSubject.value
let newItems = items + proxies
itemProxies = newItems
stateSubject.send((newItems, paginationState))
}
func replaceLastItem(with proxy: TimelineItemProxy) {
var (items, paginationState) = stateSubject.value
guard !items.isEmpty else { return }
items[items.index(before: items.endIndex)] = proxy
itemProxies = items
stateSubject.send((items, paginationState))
}
private func simulateBackPagination() throws {
let (items, _) = stateSubject.value
guard !backPaginationResponses.isEmpty else {
let newState = TimelinePaginationState(backward: .endReached, forward: .endReached)
underlyingPaginationState = newState
stateSubject.send((items, newState))
return
}
let newItems = backPaginationResponses.removeFirst()
let newPaginationState = TimelinePaginationState(backward: backPaginationResponses.isEmpty ? .endReached : .idle,
forward: .endReached)
itemProxies = newItems + items
underlyingPaginationState = newPaginationState
stateSubject.send((newItems + items, newPaginationState))
try client?.send(.success)
}
}
@@ -8,11 +8,13 @@
import Combine
import Foundation
import MatrixRustSDK
extension TimelineProxyMock {
struct Configuration {
var isAutoUpdating = false
var timelineStartReached = false
var timelineItemProvider: TimelineItemProviderProtocol?
}
@MainActor
@@ -20,13 +22,18 @@ extension TimelineProxyMock {
self.init()
sendMessageEventContentReturnValue = .success(())
sendMessageHtmlInReplyToEventIDIntentionalMentionsReturnValue = .success(())
editNewContentReturnValue = .success(())
buildMessageContentForHtmlIntentionalMentionsReturnValue = RoomMessageEventContentWithoutRelation(noHandle: .init())
paginateBackwardsRequestSizeReturnValue = .success(())
paginateForwardsRequestSizeReturnValue = .success(())
sendReadReceiptForTypeReturnValue = .success(())
createPollQuestionAnswersPollKindReturnValue = .success(())
editPollOriginalQuestionAnswersPollKindReturnValue = .success(())
if configuration.isAutoUpdating {
if let provider = configuration.timelineItemProvider {
underlyingTimelineItemProvider = provider
} else if configuration.isAutoUpdating {
underlyingTimelineItemProvider = AutoUpdatingTimelineItemProviderMock()
} else {
let timelineItemProvider = TimelineItemProviderMock()
@@ -18,8 +18,6 @@ class MockTimelineController: TimelineControllerProtocol {
/// An array of timeline item arrays that will be inserted in order for each back pagination request.
var backPaginationResponses: [[RoomTimelineItemProtocol]] = []
/// An array of timeline items that will be appended in order when ``simulateIncomingItems()`` is called.
var incomingItems: [RoomTimelineItemProtocol] = []
var roomProxy: JoinedRoomProxyProtocol?
var timelineProxy: TimelineProxyProtocol?
@@ -46,8 +44,6 @@ class MockTimelineController: TimelineControllerProtocol {
var timelineItemsTimestamp: [TimelineItemIdentifier: Date] = [:]
private var client: UITestsSignalling.Client?
static var mediaGallery: MockTimelineController {
MockTimelineController(timelineKind: .media(.mediaFilesScreen), timelineItems: (0..<5).reduce([]) { partialResult, _ in
partialResult + [RoomTimelineItemFixtures.separator] + RoomTimelineItemFixtures.mediaChunk
@@ -61,7 +57,6 @@ class MockTimelineController: TimelineControllerProtocol {
}
init(timelineKind: TimelineKind = .live,
listenForSignals: Bool = false,
timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default,
timelineProxy: TimelineProxyProtocol? = nil) {
self.timelineKind = timelineKind
@@ -70,14 +65,6 @@ class MockTimelineController: TimelineControllerProtocol {
callbacks.send(.paginationState(paginationState))
callbacks.send(.isLive(true))
guard listenForSignals else { return }
do {
try startListening()
} catch {
fatalError("Failure setting up signalling: \(error)")
}
}
private(set) var focusOnEventCallCount = 0
@@ -96,14 +83,21 @@ class MockTimelineController: TimelineControllerProtocol {
private(set) var paginateBackwardsCallCount = 0
func paginateBackwards(requestSize: UInt16) async -> Result<Void, TimelineControllerError> {
paginateBackwardsCallCount += 1
paginationState = TimelinePaginationState(backward: .paginating, forward: .endReached)
if client == nil {
try? await simulateBackPagination()
simulateBackPagination()
return .success(())
}
private func simulateBackPagination() {
defer {
paginationState = TimelinePaginationState(backward: backPaginationResponses.isEmpty ? .endReached : .idle,
forward: .endReached)
}
return .success(())
guard !backPaginationResponses.isEmpty else { return }
let newItems = backPaginationResponses.removeFirst()
timelineItems.insert(contentsOf: newItems, at: 0)
}
private(set) var paginateForwardsCallCount = 0
@@ -328,63 +322,4 @@ class MockTimelineController: TimelineControllerProtocol {
}
return .success(())
}
// MARK: - UI Test signalling
/// The cancellable used for UI Tests signalling.
private var signalCancellable: AnyCancellable?
/// Allows the simulation of server responses by listening for signals from UI tests.
private func startListening() throws {
let client = try UITestsSignalling.Client(mode: .app)
signalCancellable = client.signals.sink { [weak self] signal in
Task {
do {
try await self?.handleSignal(signal)
} catch {
MXLog.error(error.localizedDescription)
}
}
}
self.client = client
}
/// Handles a UI test signal as necessary.
private func handleSignal(_ signal: UITestsSignal) async throws {
switch signal {
case .timeline(.paginate):
try await simulateBackPagination()
case .timeline(.incomingMessage):
try await simulateIncomingItem()
default:
break
}
}
/// Appends the next incoming item to the `timelineItems` array.
private func simulateIncomingItem() async throws {
guard !incomingItems.isEmpty else { return }
let incomingItem = incomingItems.removeFirst()
timelineItems.append(incomingItem)
try client?.send(.success)
}
/// Prepends the next chunk of items to the `timelineItems` array.
private func simulateBackPagination() async throws {
defer {
paginationState = TimelinePaginationState(backward: backPaginationResponses.isEmpty ? .endReached : .idle,
forward: .endReached)
}
guard !backPaginationResponses.isEmpty else { return }
let newItems = backPaginationResponses.removeFirst()
timelineItems.insert(contentsOf: newItems, at: 0)
try client?.send(.success)
}
}
@@ -373,12 +373,14 @@ class MockScreen: Identifiable {
case .roomSmallTimelineIncomingAndSmallPagination:
let navigationStackCoordinator = NavigationStackCoordinator()
let timelineController = MockTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.singleMessageChunk]
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.smallChunkProxies,
listenForSignals: true)
itemProvider.backPaginationResponses = [RoomTimelineItemFixtures.singleMessageChunkProxies]
itemProvider.incomingItems = [RoomTimelineItemFixtures.incomingMessageProxy]
let roomProxy = JoinedRoomProxyMock(.init(name: "Small timeline", avatarURL: .mockMXCAvatar))
let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "Small timeline", avatarURL: .mockMXCAvatar)),
roomProxy: roomProxy,
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: appSettings),
@@ -398,11 +400,13 @@ class MockScreen: Identifiable {
case .roomSmallTimelineLargePagination:
let navigationStackCoordinator = NavigationStackCoordinator()
let timelineController = MockTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.smallChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.smallChunkProxies,
listenForSignals: true)
itemProvider.backPaginationResponses = [RoomTimelineItemFixtures.largeChunkProxies]
let roomProxy = JoinedRoomProxyMock(.init(name: "Small timeline, paginating", avatarURL: .mockMXCAvatar))
let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "Small timeline, paginating", avatarURL: .mockMXCAvatar)),
roomProxy: roomProxy,
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: appSettings),
@@ -421,11 +425,13 @@ class MockScreen: Identifiable {
case .roomLayoutTop:
let navigationStackCoordinator = NavigationStackCoordinator()
let timelineController = MockTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.largeChunkProxies,
listenForSignals: true)
itemProvider.backPaginationResponses = [RoomTimelineItemFixtures.largeChunkProxies]
let roomProxy = JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar))
let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar)),
roomProxy: roomProxy,
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: appSettings),
@@ -445,12 +451,14 @@ class MockScreen: Identifiable {
case .roomLayoutMiddle:
let navigationStackCoordinator = NavigationStackCoordinator()
let timelineController = MockTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
timelineController.backPaginationResponses = [RoomTimelineItemFixtures.largeChunk]
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.largeChunkProxies,
listenForSignals: true)
itemProvider.backPaginationResponses = [RoomTimelineItemFixtures.largeChunkProxies]
itemProvider.incomingItems = [RoomTimelineItemFixtures.incomingMessageProxy]
let roomProxy = JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar))
let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar)),
roomProxy: roomProxy,
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: appSettings),
@@ -470,11 +478,13 @@ class MockScreen: Identifiable {
case .roomLayoutBottom:
let navigationStackCoordinator = NavigationStackCoordinator()
let timelineController = MockTimelineController(listenForSignals: true)
timelineController.timelineItems = RoomTimelineItemFixtures.largeChunk
timelineController.incomingItems = [RoomTimelineItemFixtures.incomingMessage]
let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.largeChunkProxies,
listenForSignals: true)
itemProvider.incomingItems = [RoomTimelineItemFixtures.incomingMessageProxy]
let roomProxy = JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar))
let timelineController = makeSignallingTimelineController(itemProvider: itemProvider, roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "Large timeline", avatarURL: .mockMXCAvatar)),
roomProxy: roomProxy,
timelineController: timelineController,
mediaPlayerProvider: MediaPlayerProviderMock(),
emojiProvider: EmojiProvider(appSettings: appSettings),
@@ -532,7 +542,6 @@ class MockScreen: Identifiable {
let timelineController = MockTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.disclosedPolls
timelineController.incomingItems = []
let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: .mockMXCAvatar)),
timelineController: timelineController,
@@ -556,7 +565,6 @@ class MockScreen: Identifiable {
let timelineController = MockTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.undisclosedPolls
timelineController.incomingItems = []
let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: .mockMXCAvatar)),
timelineController: timelineController,
@@ -580,7 +588,6 @@ class MockScreen: Identifiable {
let timelineController = MockTimelineController()
timelineController.timelineItems = RoomTimelineItemFixtures.outgoingPolls
timelineController.incomingItems = []
let parameters = RoomScreenCoordinatorParameters(userSession: UserSessionMock(.init()),
roomProxy: JoinedRoomProxyMock(.init(name: "Polls timeline", avatarURL: .mockMXCAvatar)),
timelineController: timelineController,
@@ -618,6 +625,7 @@ class MockScreen: Identifiable {
} else {
.mockRooms
}
let clientProxy = ClientProxyMock(.init(userID: "@mock:client.com",
deviceID: "MOCKCLIENT",
roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(roomSummaries))),
@@ -633,6 +641,13 @@ class MockScreen: Identifiable {
let appMediator = AppMediatorMock(.init())
appMediator.underlyingWindowManager = windowManager
let itemProvider = SignallingTimelineItemProviderMock(itemProxies: RoomTimelineItemFixtures.largeChunkProxies)
let timelineController = makeSignallingTimelineController(itemProvider: itemProvider,
roomProxy: JoinedRoomProxyMock(.init()))
let timelineControllerFactory = TimelineControllerFactoryMock(.init(timelineController: timelineController))
let flowCoordinator = UserSessionFlowCoordinator(isNewLogin: false,
navigationRootCoordinator: navigationRootCoordinator,
appLockService: AppLockService(keychainController: KeychainControllerMock(),
@@ -640,7 +655,7 @@ class MockScreen: Identifiable {
flowParameters: CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy)),
bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
timelineControllerFactory: timelineControllerFactory,
emojiProvider: EmojiProvider(appSettings: appSettings),
linkMetadataProvider: LinkMetadataProvider(),
appMediator: appMediator,
@@ -852,4 +867,40 @@ class MockScreen: Identifiable {
return nil
}
}()
// MARK: - Private
private func makeSignallingTimelineController(itemProvider: SignallingTimelineItemProviderMock,
roomProxy: JoinedRoomProxyMock) -> TimelineController {
let timelineProxy = TimelineProxyMock(.init(timelineItemProvider: itemProvider))
var pendingEditText: String?
timelineProxy.sendMessageHtmlInReplyToEventIDIntentionalMentionsClosure = { [weak itemProvider] message, _, _, _ in
itemProvider?.inject([.mockOwnMessage(message)])
return .success(())
}
timelineProxy.buildMessageContentForHtmlIntentionalMentionsClosure = { text, _, _ in
pendingEditText = text
return RoomMessageEventContentWithoutRelation(noHandle: .init())
}
timelineProxy.editNewContentClosure = { [weak itemProvider] _, _ in
if let text = pendingEditText {
itemProvider?.replaceLastItem(with: .mockOwnMessage(text, isEdited: true))
pendingEditText = nil
}
return .success(())
}
return TimelineController(roomProxy: roomProxy,
timelineProxy: timelineProxy,
initialFocussedEventID: nil,
timelineItemFactory: RoomTimelineItemFactory(userID: "@alice:matrix.org",
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: "@alice:matrix.org")),
mediaProvider: MediaProviderMock(.init()),
appSettings: appSettings)
}
}