Files
element-x-ios/UnitTests/Sources/AudioPlayerStateTests.swift
T
Florian 285f8b7834 Voice message: variable play back speed (#5121)
* Implement voice message variable playback speed

* Move playback speed string to Untranslated

https://github.com/element-hq/element-x-ios/pull/5121#discussion_r2822631371

* Address review feedback for voice message playback speed

- Persist voice message playback speed as an enum in AppSettings instead of storing an index.
- Update playback speed cycling to derive from enum allCases and safely fall back to `.default` if the stored value cannot be resolved.
- Apply runtime speed updates in AudioPlayer only when the player is in the `.playing` state.
- Keep MediaPlayerProviderTests formatting/indentation style intact while retaining mock playback speed setup.
- Regenerate preview snapshots for:
  - PlaybackSpeedButton
  - VoiceMessageRoomPlaybackView
  - VoiceMessageRoomTimelineView

* Move VoiceMessagePlaybackSpeed outside AppSettings, remove speedRatio

* Stabilize PlaybackSpeedButton width

* Sync voice-message speed label

- Add voiceMessagePlaybackSpeed to TimelineViewState and bind it from appSettings.$voiceMessagePlaybackSpeed.
- Pass that timeline-level speed into VoiceMessageRoomPlaybackView and use it for PlaybackSpeedButton, so labels update consistently across items.
- Use @EnvironmentObject in VoiceMessageRoomTimelineContent so the view re-renders when timeline context state changes.
- In WaveformInteractionModifier, add .allowsHitTesting(showCursor) to the cursor interaction view so hidden pre-playback cursor hit area does not steal taps from the speed button.
2026-02-19 22:40:31 +01:00

335 lines
12 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2023-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 Testing
@MainActor
@Suite
struct AudioPlayerStateTests {
static let audioDuration = 10.0
private var audioPlayerState: AudioPlayerState!
private var audioPlayerMock: AudioPlayerMock!
private var audioPlayerActionsSubject: PassthroughSubject<AudioPlayerAction, Never>!
private var audioPlayerActions: AnyPublisher<AudioPlayerAction, Never> {
audioPlayerActionsSubject.eraseToAnyPublisher()
}
private var audioPlayerSeekCallsSubject: PassthroughSubject<Double, Never>!
private func buildAudioPlayerMock() -> AudioPlayerMock {
let audioPlayerMock = AudioPlayerMock()
audioPlayerMock.underlyingActions = audioPlayerActions
audioPlayerMock.state = .stopped
audioPlayerMock.currentTime = 0.0
audioPlayerMock.duration = 0.0
audioPlayerMock.playbackSpeed = 1.0
audioPlayerMock.seekToClosure = { [audioPlayerSeekCallsSubject] progress in
audioPlayerSeekCallsSubject?.send(progress)
}
return audioPlayerMock
}
init() async {
audioPlayerActionsSubject = .init()
audioPlayerSeekCallsSubject = .init()
audioPlayerState = AudioPlayerState(id: .timelineItemIdentifier(.randomEvent), title: "", duration: Self.audioDuration)
audioPlayerMock = buildAudioPlayerMock()
audioPlayerMock.seekToClosure = { [audioPlayerMock] progress in
audioPlayerMock?.currentTime = Self.audioDuration * progress
}
}
@Test
func attach() {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
#expect(audioPlayerState.isAttached)
#expect(audioPlayerState.playbackState == .loading)
}
@Test
mutating func detach() {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
audioPlayerState.detachAudioPlayer()
#expect(audioPlayerMock.stopCalled)
#expect(!audioPlayerState.isAttached)
#expect(audioPlayerState.playbackState == .stopped)
#expect(!audioPlayerState.showProgressIndicator)
}
@Test
func delayedState() async throws {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
#expect(audioPlayerState.isAttached)
#expect(audioPlayerState.playbackState == .loading)
#expect(audioPlayerState.playerButtonPlaybackState == .stopped)
let deferred = deferFulfillment(audioPlayerState.$playerButtonPlaybackState) { output in
switch output {
case .loading:
return true
default:
return false
}
}
try await deferred.fulfill()
#expect(audioPlayerState.playerButtonPlaybackState == .loading)
}
@Test
func otherActionsAreNotDelayed() async throws {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
#expect(audioPlayerState.playbackState == .loading)
#expect(audioPlayerState.playerButtonPlaybackState == .stopped)
let deferred = deferFulfillment(audioPlayerState.$playerButtonPlaybackState) { output in
switch output {
case .playing:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didStartPlaying)
try await deferred.fulfill()
#expect(audioPlayerState.playbackState == .playing)
#expect(audioPlayerState.playerButtonPlaybackState == .playing)
}
@Test
mutating func reportError() {
#expect(audioPlayerState.playbackState == .stopped)
audioPlayerState.reportError()
#expect(audioPlayerState.playbackState == .error)
}
@Test
mutating func updateProgress() async {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
// If we try to set a negative progress, the new progress must be 0.0
await audioPlayerState.updateState(progress: -5.0)
#expect(audioPlayerState.progress == 0.0)
#expect(audioPlayerMock.seekToReceivedProgress == 0.0)
// If we try to set a progress > 1.0, the new progress must be 1.0
await audioPlayerState.updateState(progress: 1.5)
#expect(audioPlayerState.progress == 1.0)
#expect(audioPlayerMock.seekToReceivedProgress == 1.0)
audioPlayerMock.state = .stopped
await audioPlayerState.updateState(progress: 0.4)
#expect(audioPlayerState.progress == 0.4)
#expect(audioPlayerMock.seekToReceivedProgress == 0.4)
#expect(!audioPlayerState.isPublishingProgress)
audioPlayerMock.state = .playing
await audioPlayerState.updateState(progress: 0.4)
#expect(audioPlayerState.progress == 0.4)
#expect(audioPlayerMock.seekToReceivedProgress == 0.4)
#expect(audioPlayerState.isPublishingProgress)
}
@Test
func handlingAudioPlayerActionDidStartLoading() async throws {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
let deferred = deferFulfillment(audioPlayerState.$playbackState) { action in
switch action {
case .loading:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didStartLoading)
try await deferred.fulfill()
#expect(audioPlayerState.playbackState == .loading)
}
@Test
mutating func handlingAudioPlayerActionDidFinishLoading() async throws {
audioPlayerMock.duration = 10.0
audioPlayerState = AudioPlayerState(id: .timelineItemIdentifier(.randomEvent), title: "", duration: 0)
audioPlayerState.attachAudioPlayer(audioPlayerMock)
let deferred = deferFulfillment(audioPlayerState.$playbackState) { action in
switch action {
case .readyToPlay:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didFinishLoading)
try await deferred.fulfill()
// The state is expected to be .readyToPlay
#expect(audioPlayerState.playbackState == .readyToPlay)
// The duration should have been updated with the player's duration
#expect(audioPlayerState.duration == audioPlayerMock.duration)
}
@Test
mutating func handlingAudioPlayerActionDidStartPlaying() async throws {
await audioPlayerState.updateState(progress: 0.4)
audioPlayerState.attachAudioPlayer(audioPlayerMock)
let deferred = deferFulfillment(audioPlayerState.$playbackState) { action in
switch action {
case .playing:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didStartPlaying)
try await deferred.fulfill()
#expect(audioPlayerMock.seekToReceivedProgress == 0.4)
#expect(audioPlayerState.playbackState == .playing)
#expect(audioPlayerState.isPublishingProgress)
#expect(audioPlayerState.showProgressIndicator)
}
@Test
mutating func handlingAudioPlayerActionDidPausePlaying() async throws {
await audioPlayerState.updateState(progress: 0.4)
audioPlayerState.attachAudioPlayer(audioPlayerMock)
let deferred = deferFulfillment(audioPlayerState.$playbackState) { action in
switch action {
case .stopped:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didPausePlaying)
try await deferred.fulfill()
#expect(audioPlayerState.playbackState == .stopped)
#expect(audioPlayerState.progress == 0.4)
#expect(!audioPlayerState.isPublishingProgress)
#expect(audioPlayerState.showProgressIndicator)
}
@Test
mutating func handlingAudioPlayerActionsidStopPlaying() async throws {
await audioPlayerState.updateState(progress: 0.4)
audioPlayerState.attachAudioPlayer(audioPlayerMock)
let deferred = deferFulfillment(audioPlayerState.$playbackState) { action in
switch action {
case .stopped:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didStopPlaying)
try await deferred.fulfill()
#expect(audioPlayerState.playbackState == .stopped)
#expect(audioPlayerState.progress == 0.4)
#expect(!audioPlayerState.isPublishingProgress)
#expect(audioPlayerState.showProgressIndicator)
}
@Test
mutating func audioPlayerActionsDidFinishPlaying() async throws {
await audioPlayerState.updateState(progress: 0.4)
audioPlayerState.attachAudioPlayer(audioPlayerMock)
let deferred = deferFulfillment(audioPlayerState.$playbackState) { action in
switch action {
case .stopped:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didFinishPlaying)
try await deferred.fulfill()
#expect(audioPlayerState.playbackState == .stopped)
// Progress should be reset to 0
#expect(audioPlayerState.progress == 0.0)
#expect(!audioPlayerState.isPublishingProgress)
#expect(!audioPlayerState.showProgressIndicator)
}
func testSetPlaybackSpeed() {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
XCTAssertEqual(audioPlayerState.playbackSpeed, 1.0)
audioPlayerState.setPlaybackSpeed(1.5)
XCTAssertEqual(audioPlayerState.playbackSpeed, 1.5)
XCTAssertEqual(audioPlayerMock.setPlaybackSpeedReceivedSpeed, 1.5)
audioPlayerState.setPlaybackSpeed(2.0)
XCTAssertEqual(audioPlayerState.playbackSpeed, 2.0)
XCTAssertEqual(audioPlayerMock.setPlaybackSpeedReceivedSpeed, 2.0)
audioPlayerState.setPlaybackSpeed(0.5)
XCTAssertEqual(audioPlayerState.playbackSpeed, 0.5)
XCTAssertEqual(audioPlayerMock.setPlaybackSpeedReceivedSpeed, 0.5)
}
func testSetPlaybackSpeedWithoutPlayer() {
XCTAssertEqual(audioPlayerState.playbackSpeed, 1.0)
audioPlayerState.setPlaybackSpeed(2.0)
XCTAssertEqual(audioPlayerState.playbackSpeed, 2.0)
}
@Test
func audioPlayerActionsDidFailed() async throws {
audioPlayerState.attachAudioPlayer(audioPlayerMock)
let deferredPlayingState = deferFulfillment(audioPlayerState.$playbackState) { action in
switch action {
case .playing:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didStartPlaying)
try await deferredPlayingState.fulfill()
#expect(!audioPlayerState.showProgressIndicator)
let deferred = deferFulfillment(audioPlayerState.$playbackState) { action in
switch action {
case .error:
return true
default:
return false
}
}
audioPlayerActionsSubject.send(.didFailWithError(error: AudioPlayerError.genericError))
try await deferred.fulfill()
#expect(audioPlayerState.playbackState == .error)
#expect(!audioPlayerState.isPublishingProgress)
#expect(!audioPlayerState.showProgressIndicator)
}
}