mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
b31950789f
* 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>
381 lines
18 KiB
Swift
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()
|
|
}
|
|
}
|