Files
element-x-ios/UnitTests/Sources/MediaUploadPreviewScreenViewModelTests.swift
T
Mauro b31950789f Move synchronous image upload preparation off the main actor (#6323)
* Move synchronous image upload preparation off the main actor

Signed-off-by: Ivan Magda <imagda15@gmail.com>

* Make media preparation cooperative with cancellation

Replace the dispatch queue with concurrent copy, image and video-thumbnail
functions. Return sendable upload metadata directly, bound each batch to
two preparations and discard unfinished results when cancellation occurs.

Keep file metadata reads synchronous and prevent the preview from sending
a result after its preparation task has been cancelled. Replace unrelated
tests with cancellation and batch-order coverage requested during review.

* Update MediaUploadingPreprocessor.swift

* Run media preparation in a nonisolated preprocessor

Make MediaUploadingPreprocessor a nonisolated struct whose copy, image
processing and video thumbnail run in @concurrent instance methods,
instead of a main-actor type with static helpers and an extra setting
parameter.

Check cancellation with plain early returns between the slow steps,
name the batch limit, stop logging cancelled video exports as errors,
and restore develop's structure and doc comments where they were changed
without need.

Replace the tests that didn't reach the new code with tests for cleanup
on failure, on batch failure and on cancellation, and make the preview
test set up its blocking mock before the view model starts processing.

* Apply review suggestions to media preparation

Docs:
- Drop the doc comment on maximumConcurrentProcessingCount, the name says it.
- Move the cleanup note into the main description of both processMedia
  functions, so the Returns lines match develop again.

Tests:
- Rename the test helper to copyResourceToTemporaryDirectory(_:).

---------

Signed-off-by: Ivan Magda <imagda15@gmail.com>
Co-authored-by: Ivan Magda <imagda15@gmail.com>
2026-10-13 17:33:22 +02:00

381 lines
18 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.
//
@testable import ElementX
import Foundation
import Testing
@MainActor
final class MediaUploadPreviewScreenViewModelTests {
var timelineProxy: TimelineProxyMock!
var clientProxy: ClientProxyMock!
var userIndicatorController: UserIndicatorControllerMock
private let userSettings: UserSettings
var viewModel: MediaUploadPreviewScreenViewModel!
var context: MediaUploadPreviewScreenViewModel.Context {
viewModel.context
}
enum TestError: Swift.Error {
case unexpectedParameter
case unknown
}
init() {
userSettings = UserSettings.mock()
userSettings.app.optimizeMediaUploads = false
userIndicatorController = UserIndicatorControllerMock()
}
@Test
func cancelWhileSendWaitsForPreparation() async throws {
// Given a screen whose media preparation is still running.
let (startedStream, startedContinuation) = AsyncStream<Void>.makeStream()
let (resumeStream, resumeContinuation) = AsyncStream<Void>.makeStream()
defer { resumeContinuation.finish() }
let preparationStarted = deferFulfillment(startedStream) { _ in true }
setUpViewModel(urls: [imageURL], expectedCaption: nil) {
startedContinuation.yield()
var iterator = resumeStream.makeAsyncIterator()
_ = await iterator.next() // Only returns once the preparation is cancelled.
return .success(100 * 1024 * 1024)
}
try await preparationStarted.fulfill()
// When sending and then dismissing the screen before the preparation finishes.
context.send(viewAction: .send)
let loadingFinished = deferFulfillment(context.observe(\.viewState.shouldDisableInteraction)) { !$0 }
context.send(viewAction: .cancel)
viewModel.stopProcessing() // Called by the coordinator when the screen is dismissed.
try await loadingFinished.fulfill()
// Then nothing is sent and no error is shown.
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 0)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only the loading indicator should be shown")
#expect(context.viewState.bindings.alertInfo == nil)
}
@Test
func imageUploadWithoutCaption() async throws {
setUpViewModel(urls: [imageURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
@Test
func imageUploadWithBlankCaption() async throws {
setUpViewModel(urls: [imageURL], expectedCaption: nil)
context.caption = .init(" ")
try await send()
}
@Test
func imageUploadWithCaption() async throws {
let caption = "This is a really great image!"
setUpViewModel(urls: [imageURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
@Test(.timeLimit(.minutes(2))) // Transcoding videos can be slow on a busy CI runner.
func videoUploadWithoutCaption() async throws {
setUpViewModel(urls: [videoURL], expectedCaption: nil)
context.caption = .init("")
// The preprocessor transcodes the video for real which can take a while on a busy CI runner.
try await send(timeout: .seconds(60))
}
@Test(.timeLimit(.minutes(2))) // Transcoding videos can be slow on a busy CI runner.
func videoUploadWithCaption() async throws {
let caption = "Check out this video!"
setUpViewModel(urls: [videoURL], expectedCaption: caption)
context.caption = .init(string: caption)
// The preprocessor transcodes the video for real which can take a while on a busy CI runner.
try await send(timeout: .seconds(60))
}
@Test
func audioUploadWithoutCaption() async throws {
setUpViewModel(urls: [audioURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
@Test
func audioUploadWithCaption() async throws {
let caption = "Listen to this!"
setUpViewModel(urls: [audioURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
@Test
func fileUploadWithoutCaption() async throws {
setUpViewModel(urls: [fileURL], expectedCaption: nil)
context.caption = .init("")
try await send()
}
@Test
func fileUploadWithCaption() async throws {
let caption = "Please will you check my article."
setUpViewModel(urls: [fileURL], expectedCaption: caption)
context.caption = .init(string: caption)
try await send()
}
@Test
func processingFailure() async throws {
// Given an upload screen for a non-existent file.
setUpViewModel(urls: [badImageURL], expectedCaption: nil)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the file
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the failure should occur preventing the screen from being dismissed.
// The interaction being re-enabled is what marks the end of the send, the timeout above only proves
// that no dismissal happened within it — processing may still be in flight when it elapses.
let deferredInteraction = deferFulfillment(context.observe(\.viewState.shouldDisableInteraction)) { !$0 }
try await deferredFailure.fulfill()
try await deferredInteraction.fulfill()
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
}
@Test
func uploadWithUnknownMaxUploadSize() async throws {
// Given an upload screen that is unable to fetch the max upload size.
setUpViewModel(urls: [imageURL], expectedCaption: nil, maxUploadSizeResult: .failure(.sdkError(ClientProxyMockError.generic)))
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo == nil)
// When attempting to send the media.
let deferredAlert = deferFulfillment(context.observe(\.viewState.bindings.alertInfo)) { $0 != nil }
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
// Then alert should be shown to tell the user it failed.
try await deferredAlert.fulfill()
try await deferredFailure.fulfill()
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo?.id == .maxUploadSizeUnknown)
// When trying with the max upload size now available.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
clientProxy.underlyingMaxMediaUploadSize = .success(100 * 1024 * 1024)
context.alertInfo?.primaryButton.action?()
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while retrying.")
// Then the file should upload successfully.
try await deferredDismiss.fulfill()
}
@Test
func uploadExceedingMaxUploadSize() async throws {
// Given an upload screen with a really small max upload size.
setUpViewModel(urls: [imageURL], expectedCaption: nil, maxUploadSizeResult: .success(100))
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo == nil)
// When attempting to send an image that is larger the limit.
let deferredAlert = deferFulfillment(context.observe(\.viewState.bindings.alertInfo)) { $0 != nil }
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
// Then an alert should be shown to inform the user of the max upload size.
try await deferredAlert.fulfill()
try await deferredFailure.fulfill()
#expect(!context.viewState.shouldDisableInteraction)
#expect(context.alertInfo?.id == .maxUploadSizeExceeded(limit: 100))
}
@Test
func gallery() async throws {
// Given an upload screen with multiple media files.
setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the files.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the screen should be dismissed once the gallery has been sent in a single request.
try await deferredDismiss.fulfill()
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 1)
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDReceivedArguments?.itemInfos.count == 3)
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 0)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 0)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only a loading indicator should be shown.")
}
@Test
func galleryWithProcessingFailure() async throws {
// Given an upload screen for a non-existent file.
setUpViewModel(urls: [imageURL, fileURL, badImageURL], expectedCaption: nil)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the file
let deferredFailure = deferFailure(viewModel.actions, timeout: .seconds(1), message: "The screen should remain visible.") { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the failure should occur preventing the screen from being dismissed.
// Preprocessing the valid files can outlast the timeout above, so wait for the interaction to be
// re-enabled rather than assuming the send has finished by the time it elapses.
let deferredInteraction = deferFulfillment(context.observe(\.viewState.shouldDisableInteraction)) { !$0 }
try await deferredFailure.fulfill()
try await deferredInteraction.fulfill()
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
}
@Test
func galleryWithSendFailure() async throws {
// Given an upload screen with multiple media files where the gallery send fails.
setUpViewModel(urls: [fileURL, imageURL, imageURL, fileURL], expectedCaption: nil, simulateGallerySendFailure: true)
#expect(!context.viewState.shouldDisableInteraction)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 0)
// When attempting to send the files.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) // Loading indicator
// Then the screen should still be dismissed and an error indicator surfaced.
try await deferredDismiss.fulfill()
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 1)
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 2, "An error indicator should be shown.")
}
@Test
func galleryDisabledSendsIndividualMessages() async throws {
// Given an upload screen with multiple media files and gallery sending disabled.
setUpViewModel(urls: [fileURL, imageURL, fileURL], expectedCaption: nil, galleryEnabled: false)
#expect(!context.viewState.shouldDisableInteraction)
// When attempting to send the files.
let deferredDismiss = deferFulfillment(viewModel.actions) { $0 == .dismiss }
context.send(viewAction: .send)
// Then each file should be sent as an individual message rather than a single gallery.
try await deferredDismiss.fulfill()
#expect(timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDCallsCount == 0)
#expect(timelineProxy.sendFileUrlFileInfoCaptionRequestHandleCallsCount == 2)
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 1)
}
// MARK: - Helpers
private var audioURL: URL {
assertResourceURL(filename: "test_audio.mp3")
}
private var fileURL: URL {
assertResourceURL(filename: "test_pdf.pdf")
}
private var imageURL: URL {
assertResourceURL(filename: "test_animated_image.gif")
}
private var videoURL: URL {
assertResourceURL(filename: "landscape_test_video.mov")
}
private var badImageURL = URL(filePath: "/home/user/this_file_doesn't_exist.jpg")
private func assertResourceURL(filename: String) -> URL {
guard let url = Bundle(for: Self.self).url(forResource: filename, withExtension: nil) else {
Issue.record("Failed retrieving test asset")
return .picturesDirectory
}
return url
}
private func setUpViewModel(urls: [URL],
expectedCaption: String?,
maxUploadSizeResult: Result<UInt, ClientProxyError>? = nil,
simulateGallerySendFailure: Bool = false,
galleryEnabled: Bool = true,
maxUploadSizeClosure: (() async -> Result<UInt, ClientProxyError>)? = nil) {
timelineProxy = TimelineProxyMock(.init())
timelineProxy.sendAudioUrlAudioInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendFileUrlFileInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendVideoUrlThumbnailURLVideoInfoCaptionRequestHandleClosure = { [weak self] _, _, _, caption, _ in
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
timelineProxy.sendGalleryItemInfosCaptionInReplyToEventIDClosure = { [weak self] _, caption, _ in
guard !simulateGallerySendFailure else { return .failure(.sdkError(TestError.unknown)) }
return self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
}
clientProxy = ClientProxyMock(.init())
if let maxUploadSizeResult {
clientProxy.underlyingMaxMediaUploadSize = maxUploadSizeResult
}
clientProxy.maxMediaUploadSizeClosure = maxUploadSizeClosure
viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: urls,
caption: nil,
title: "Some File",
shouldShowCaptionWarning: true,
galleryEnabled: galleryEnabled,
mediaUploadingPreprocessor: MediaUploadingPreprocessor(userSettings: userSettings),
timelineController: TimelineControllerMock(.init(timelineProxy: timelineProxy)),
clientProxy: clientProxy,
userIndicatorController: userIndicatorController)
}
private func verifyCaption(_ caption: String?, expectedCaption: String?) -> Result<Void, TimelineProxyError> {
guard caption == expectedCaption else {
Issue.record("The sent caption '\(caption ?? "nil")' does not match the expected value '\(expectedCaption ?? "nil")'")
return .failure(.sdkError(TestError.unexpectedParameter))
}
return .success(())
}
private func send(timeout: Duration = .seconds(10)) async throws {
#expect(!context.viewState.shouldDisableInteraction, "Attempting to send when interaction is disabled.")
let deferred = deferFulfillment(viewModel.actions, timeout: timeout) { $0 == .dismiss }
context.send(viewAction: .send)
#expect(context.viewState.shouldDisableInteraction, "The interaction should be disabled while sending.")
try await deferred.fulfill()
}
}