From 5ebe8fbfb5c046d4ccb7e506b403c3373e7c6de3 Mon Sep 17 00:00:00 2001 From: Ilias Pavlidakis Date: Mon, 7 Oct 2024 20:51:11 +0300 Subject: [PATCH] [Fix]Capture quality adaptive policy criteria (#563) --- .../Utils/ThermalStateObserver.swift | 9 + .../VideoCapturePolicy.swift | 39 +- StreamVideo.xcodeproj/project.pbxproj | 8 + .../CallView/VideoRenderer_Tests.swift | 75 +- .../Controllers/CallController_Tests.swift | 9 +- .../Mock/MockThermalStateObserver.swift | 47 ++ .../AdaptiveVideoCapturePolicy_tests.swift | 718 +++++++++++++++++- .../WebRTC/v2/WebRTCCoorindator_Tests.swift | 10 +- .../WebRTC/v2/WebRTCStateAdapter_Tests.swift | 20 +- 9 files changed, 870 insertions(+), 65 deletions(-) create mode 100644 StreamVideoTests/Mock/MockThermalStateObserver.swift diff --git a/Sources/StreamVideo/Utils/ThermalStateObserver.swift b/Sources/StreamVideo/Utils/ThermalStateObserver.swift index 7da71ca58..3c3107f9e 100644 --- a/Sources/StreamVideo/Utils/ThermalStateObserver.swift +++ b/Sources/StreamVideo/Utils/ThermalStateObserver.swift @@ -108,6 +108,15 @@ final class ThermalStateObserver: ObservableObject, ThermalStateObserving { } } +extension ProcessInfo.ThermalState: Comparable { + public static func < ( + lhs: ProcessInfo.ThermalState, + rhs: ProcessInfo.ThermalState + ) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + /// Provides the default value of the `Appearance` class. enum ThermalStateObserverKey: InjectionKey { static var currentValue: any ThermalStateObserving = ThermalStateObserver.shared diff --git a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/VideoCapturePolicy/VideoCapturePolicy.swift b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/VideoCapturePolicy/VideoCapturePolicy.swift index 74dd13393..f8efcd18b 100644 --- a/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/VideoCapturePolicy/VideoCapturePolicy.swift +++ b/Sources/StreamVideo/WebRTC/v2/PeerConnection/MediaAdapters/VideoCapturePolicy/VideoCapturePolicy.swift @@ -2,6 +2,8 @@ // Copyright © 2024 Stream.io Inc. All rights reserved. // +import Foundation + /// Defines a video capture policy used by the `LocalVideoAdapter` to adjust video capture quality based on /// instructions from the SFU (Selective Forwarding Unit). /// @@ -23,15 +25,35 @@ public class VideoCapturePolicy: @unchecked Sendable { ) async throws { /* No operation by default */ } } -/// A final class that adapts the video capture quality dynamically. +/// A final class that adapts the video capture quality dynamically. The policy requires the following criteria +/// in order to start adapting the capture quality: +/// - Either the thermal state is `.serious` or higher **or** the current device doesn't have a +/// neuralEngine (which is required for efficiently resizing frames). +/// - Requested encodings to activate are different than the currently activated ones. +/// - There is a running videoCapturingSession final class AdaptiveVideoCapturePolicy: VideoCapturePolicy, @unchecked Sendable { + + @Injected(\.thermalStateObserver) private var thermalStateObserver + + private let neuralEngineExistsProvider: () -> Bool + private var lastActiveEncodings: Set? + + init(_ neuralEngineExistsProvider: @escaping () -> Bool) { + self.neuralEngineExistsProvider = neuralEngineExistsProvider + super.init() + } + /// Overrides the method to update capture quality using an adaptive policy. override func updateCaptureQuality( with activeEncodings: Set, for activeSession: VideoCaptureSession? ) async throws { /// Ensure there is an active session to work with. - guard let activeSession else { return } + guard + shouldUpdateCaptureQuality, + lastActiveEncodings != activeEncodings, + let activeSession + else { return } /// Filter the default video codecs to include only those matching the active encodings. let videoCodecs = VideoCodec @@ -40,6 +62,17 @@ final class AdaptiveVideoCapturePolicy: VideoCapturePolicy, @unchecked Sendable try await activeSession.capturer .updateCaptureQuality(videoCodecs, on: activeSession.device) + lastActiveEncodings = activeEncodings + log.debug( + "Video capture quality adapted to [\(activeEncodings.sorted().joined(separator: ","))].", + subsystems: .webRTC + ) + } + + // MARK: - Private helpers + + private var shouldUpdateCaptureQuality: Bool { + thermalStateObserver.state > .fair || !neuralEngineExistsProvider() } } @@ -57,7 +90,7 @@ extension VideoCapturePolicy { /// This `adaptive` policy adjusts the device's video capture quality to match the quality requested /// by the SFU (Selective Forwarding Unit). By capturing video at the requested quality, it helps /// reduce processing overhead and improve performance, especially on older or less powerful devices. - static let adaptive: VideoCapturePolicy = AdaptiveVideoCapturePolicy() + static let adaptive: VideoCapturePolicy = AdaptiveVideoCapturePolicy { neuralEngineExists } } extension VideoCapturePolicy: InjectionKey { diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index 62f4552af..339364355 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -446,6 +446,9 @@ 40C4DF572C1C61BD0035DBC2 /* URL+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A64B22A9DF86200534ED1 /* URL+Convenience.swift */; }; 40C689182C64DDC70054528A /* Publisher+TaskSink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C689172C64DDC70054528A /* Publisher+TaskSink.swift */; }; 40C6891C2C657F280054528A /* Publisher+AsyncStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C6891B2C657F280054528A /* Publisher+AsyncStream.swift */; }; + 40C75BB72CB4044600C167C3 /* MockThermalStateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C75BB62CB4044600C167C3 /* MockThermalStateObserver.swift */; }; + 40C75BB82CB4045100C167C3 /* MockThermalStateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C75BB62CB4044600C167C3 /* MockThermalStateObserver.swift */; }; + 40C75BB92CB40D8700C167C3 /* Mockable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4013387B2BF248E9007318BD /* Mockable.swift */; }; 40C7B82C2B612D6000FB9DB2 /* ParticipantsListViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C7B82B2B612D6000FB9DB2 /* ParticipantsListViewModifier.swift */; }; 40C7B8322B61325500FB9DB2 /* ModalButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C7B8312B61325500FB9DB2 /* ModalButton.swift */; }; 40C7B8342B613A8200FB9DB2 /* ControlBadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40C7B8332B613A8200FB9DB2 /* ControlBadgeView.swift */; }; @@ -1701,6 +1704,7 @@ 40C689192C64F74F0054528A /* SFUSignalService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFUSignalService.swift; sourceTree = ""; }; 40C6891B2C657F280054528A /* Publisher+AsyncStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+AsyncStream.swift"; sourceTree = ""; }; 40C6891D2C6661990054528A /* SFUEventAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFUEventAdapter.swift; sourceTree = ""; }; + 40C75BB62CB4044600C167C3 /* MockThermalStateObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockThermalStateObserver.swift; sourceTree = ""; }; 40C7B82B2B612D6000FB9DB2 /* ParticipantsListViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantsListViewModifier.swift; sourceTree = ""; }; 40C7B8312B61325500FB9DB2 /* ModalButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalButton.swift; sourceTree = ""; }; 40C7B8332B613A8200FB9DB2 /* ControlBadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlBadgeView.swift; sourceTree = ""; }; @@ -4620,6 +4624,7 @@ 8492B87629081CE700006649 /* Mock */ = { isa = PBXGroup; children = ( + 40C75BB62CB4044600C167C3 /* MockThermalStateObserver.swift */, 40FE5EBC2C9C82A6006B0881 /* MockRTCVideoCapturerDelegate.swift */, 40483CB92C9B1E6000B4FCA8 /* MockWebRTCCoordinatorFactory.swift */, 40AF6A3A2C93469000BA2935 /* MockWebSocketClientFactory.swift */, @@ -6606,6 +6611,7 @@ 40C9E4552C988CE100802B28 /* WebRTCJoinRequestFactory_Tests.swift in Sources */, 84F58B9329EEB53E00010C4C /* EventMiddleware_Mock.swift in Sources */, 40FE5EBF2C9C82CD006B0881 /* CVPixelBuffer+Convenience.swift in Sources */, + 40C75BB72CB4044600C167C3 /* MockThermalStateObserver.swift in Sources */, 842747EC29EED59000E063AD /* JSONDecoder_Tests.swift in Sources */, 406B3C142C8F870400FC93A1 /* MockActiveCallProvider.swift in Sources */, 841FF5052A5D815700809BBB /* VideoCapturerUtils_Tests.swift in Sources */, @@ -6898,6 +6904,7 @@ 40245F452BE2746300FCF075 /* CallIngressResponse+Dummy.swift in Sources */, 40245F462BE2746300FCF075 /* GeofenceSettings+Dummy.swift in Sources */, 40245F472BE2746300FCF075 /* CallRejectedEvent+Dummy.swift in Sources */, + 40C75BB92CB40D8700C167C3 /* Mockable.swift in Sources */, 40FE5EC12C9C82CD006B0881 /* CVPixelBuffer+Convenience.swift in Sources */, 40245F482BE2746300FCF075 /* CallParticipantResponse+Dummy.swift in Sources */, 40245F492BE2746300FCF075 /* EgressHLSResponse+Dummy.swift in Sources */, @@ -6913,6 +6920,7 @@ 40245F672BE27B8400FCF075 /* StatelessSpeakerIconView_Tests.swift in Sources */, 40245F692BE27CCB00FCF075 /* StatelessParticipantsListButton_Tests.swift in Sources */, 40245F532BE2746300FCF075 /* TranscriptionSettings+Dummy.swift in Sources */, + 40C75BB82CB4045100C167C3 /* MockThermalStateObserver.swift in Sources */, 40245F542BE2746300FCF075 /* BroadcastSettingsResponse+Dummy.swift in Sources */, 40245F552BE2746300FCF075 /* UserResponse+Dummy.swift in Sources */, 40245F562BE2746300FCF075 /* ScreensharingSettings+Dummy.swift in Sources */, diff --git a/StreamVideoSwiftUITests/CallView/VideoRenderer_Tests.swift b/StreamVideoSwiftUITests/CallView/VideoRenderer_Tests.swift index 350bcde55..665af7b69 100644 --- a/StreamVideoSwiftUITests/CallView/VideoRenderer_Tests.swift +++ b/StreamVideoSwiftUITests/CallView/VideoRenderer_Tests.swift @@ -8,51 +8,70 @@ import StreamSwiftTestHelpers @testable import StreamVideoSwiftUI import XCTest +@MainActor final class VideoRenderer_Tests: XCTestCase { - private var mockThermalStateObserver: MockThermalStateObserver! = .init() - private var subject: VideoRenderer! - - override func setUp() { - super.setUp() - - InjectedValues[\.thermalStateObserver] = mockThermalStateObserver - subject = .init(frame: .zero) - } + private lazy var thermalStateSubject: PassthroughSubject! = .init() + private lazy var mockThermalStateObserver: MockThermalStateObserver! = .init() + private lazy var maximumFramesPerSecond: Int! = UIScreen.main.maximumFramesPerSecond + private lazy var subject: VideoRenderer! = .init(frame: .zero) override func tearDown() { + InjectedValues[\.thermalStateObserver] = ThermalStateObserver { .nominal } + thermalStateSubject = nil mockThermalStateObserver = nil + maximumFramesPerSecond = nil + subject = nil super.tearDown() } // MARK: - preferredFramesPerSecond - func testFPSForNominalThermalState() { - mockThermalStateObserver.state = .nominal - XCTAssertEqual(subject.preferredFramesPerSecond, UIScreen.main.maximumFramesPerSecond) + func testFPSForNominalThermalState() async { + await assertPreferredFramesPerSecond( + thermalState: .nominal, + expected: Double(maximumFramesPerSecond) + ) } - func testFPSForFairThermalState() { - mockThermalStateObserver.state = .fair - XCTAssertEqual(subject.preferredFramesPerSecond, UIScreen.main.maximumFramesPerSecond) + func testFPSForFairThermalState() async { + await assertPreferredFramesPerSecond( + thermalState: .fair, + expected: Double(maximumFramesPerSecond) + ) } - func testFPSForSeriousThermalState() { - mockThermalStateObserver.state = .serious - XCTAssertEqual(subject.preferredFramesPerSecond, Int(Double(UIScreen.main.maximumFramesPerSecond) * 0.5)) + func testFPSForSeriousThermalState() async { + await assertPreferredFramesPerSecond( + thermalState: .serious, + expected: Double(maximumFramesPerSecond) * 0.5 + ) } - func testFPSForCriticalThermalState() { - mockThermalStateObserver.state = .critical - XCTAssertEqual(subject.preferredFramesPerSecond, Int(Double(UIScreen.main.maximumFramesPerSecond) * 0.4)) + func testFPSForCriticalThermalState() async { + await assertPreferredFramesPerSecond( + thermalState: .critical, + expected: Double(maximumFramesPerSecond) * 0.4 + ) } -} -// MARK: - Private Helpers + // MARK: - Private helpers + + private func assertPreferredFramesPerSecond( + thermalState: ProcessInfo.ThermalState, + expected: Double, + file: StaticString = #file, + line: UInt = #line + ) async { + mockThermalStateObserver.stub( + for: \.statePublisher, + with: thermalStateSubject.eraseToAnyPublisher() + ) + _ = subject + thermalStateSubject.send(thermalState) -private final class MockThermalStateObserver: ThermalStateObserving { - var state: ProcessInfo.ThermalState = .nominal { didSet { stateSubject.send(state) } } - lazy var stateSubject: CurrentValueSubject = .init(state) - var statePublisher: AnyPublisher { stateSubject.eraseToAnyPublisher() } - var scale: CGFloat = 1 + await fulfillment(file: file, line: line) { + [subject] in subject?.preferredFramesPerSecond == Int(expected) + } + } } diff --git a/StreamVideoTests/Controllers/CallController_Tests.swift b/StreamVideoTests/Controllers/CallController_Tests.swift index c2306232d..945bc00b8 100644 --- a/StreamVideoTests/Controllers/CallController_Tests.swift +++ b/StreamVideoTests/Controllers/CallController_Tests.swift @@ -294,12 +294,13 @@ final class CallController_Tests: StreamVideoTestCase, @unchecked Sendable { .coordinator .changePinState(isEnabled: false, sessionId: user.id) - await assertNilAsync( - await mockWebRTCCoordinatorFactory + await fulfillment { + await self + .mockWebRTCCoordinatorFactory .mockCoordinatorStack .coordinator - .stateAdapter.participants[user.id]?.pin - ) + .stateAdapter.participants[self.user.id]?.pin == nil + } } // MARK: - startNoiseCancellation diff --git a/StreamVideoTests/Mock/MockThermalStateObserver.swift b/StreamVideoTests/Mock/MockThermalStateObserver.swift new file mode 100644 index 000000000..0efb6cc55 --- /dev/null +++ b/StreamVideoTests/Mock/MockThermalStateObserver.swift @@ -0,0 +1,47 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Combine +@testable import StreamVideo + +final class MockThermalStateObserver: ThermalStateObserving, Mockable { + + // MARK: - Mockable + + typealias FunctionKey = MockFunctionKey + typealias FunctionInputKey = EmptyPayloadable + var stubbedProperty: [String: Any] = [:] + var stubbedFunction: [FunctionKey: Any] = [:] + @Atomic var stubbedFunctionInput: [FunctionKey: [FunctionInputKey]] = [:] + func stub(for keyPath: KeyPath, with value: T) { + stubbedProperty[propertyKey(for: keyPath)] = value + } + + func stub(for function: FunctionKey, with value: T) {} + + enum MockFunctionKey: Hashable, CaseIterable {} + + // MARK: - Properties + + var state: ProcessInfo.ThermalState { + get { self[dynamicMember: \.state] } + set { _ = newValue } + } + + var statePublisher: AnyPublisher { + get { self[dynamicMember: \.statePublisher] } + set { _ = newValue } + } + + var scale: CGFloat { + get { self[dynamicMember: \.scale] } + set { _ = newValue } + } + + init() { + InjectedValues[\.thermalStateObserver] = self + stub(for: \.state, with: .nominal) + stub(for: \.scale, with: 1) + } +} diff --git a/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/VideoCapturePolicy/AdaptiveVideoCapturePolicy_tests.swift b/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/VideoCapturePolicy/AdaptiveVideoCapturePolicy_tests.swift index 504479e2e..59f4cfc42 100644 --- a/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/VideoCapturePolicy/AdaptiveVideoCapturePolicy_tests.swift +++ b/StreamVideoTests/WebRTC/v2/PeerConnection/MediaAdapters/VideoCapturePolicy/AdaptiveVideoCapturePolicy_tests.swift @@ -9,6 +9,8 @@ import XCTest final class AdaptiveVideoCapturePolicy_Tests: XCTestCase { + private static var thermalStateObserver: MockThermalStateObserver! = .init() + private lazy var device: AVCaptureDevice! = .init(uniqueID: .unique) private lazy var peerConnectionFactory: PeerConnectionFactory! = .mock() private lazy var videoTrack: RTCVideoTrack! = ( @@ -22,10 +24,16 @@ final class AdaptiveVideoCapturePolicy_Tests: XCTestCase { localTrack: videoTrack, capturer: cameraVideoCapturer ) - private lazy var subject: AdaptiveVideoCapturePolicy! = .init() + private var subject: AdaptiveVideoCapturePolicy! // MARK: - Lifecycle + override class func tearDown() { + Self.thermalStateObserver = nil + InjectedValues[\.thermalStateObserver] = ThermalStateObserver.shared + super.tearDown() + } + override func tearDown() { subject = nil activeCaptureSession = nil @@ -38,37 +46,701 @@ final class AdaptiveVideoCapturePolicy_Tests: XCTestCase { // MARK: - updateCaptureQuality - func test_updateCaptureQuality_fullHalfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec() async throws { - try await assertUpdateCaptureQuality(expected: [.full, .half, .quarter]) + // MARK: ThermalState: .nominal | neuralEngineExists: false + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineDoesNotExist_fullHalfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full, .half, .quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineDoesNotExist_halfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .half, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineDoesNotExist_fullAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .full, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineDoesNotExist_fullEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineDoesNotExist_halfEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.half], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineDoesNotExist_quarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineDoesNotExist_quarterEncodingsMatchTheCurrentlyActiveOnes_capturerWasNotCalledASecondTime( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .nominal + ) + + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .nominal + ) + } + + // MARK: ThermalState: .nominal | neuralEngineExists: true + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineExists_fullHalfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full, .half, .quarter], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineExists_halfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .half, + .quarter + ], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineExists_fullAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .full, + .quarter + ], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineExists_fullEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineExists_halfEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.half], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineExists_quarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .nominal + ) + } + + func test_updateCaptureQuality_thermalStateNominal_neuralEngineExists_quarterEncodingsMatchTheCurrentlyActiveOnes_capturerWasNotCalledASecondTime( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .nominal + ) + + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .nominal + ) + } + + // MARK: ThermalState: .fair | neuralEngineExists: false + + func test_updateCaptureQuality_thermalStateFair_neuralEngineDoesNotExist_fullHalfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full, .half, .quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineDoesNotExist_halfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .half, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineDoesNotExist_fullAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .full, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineDoesNotExist_fullEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineDoesNotExist_halfEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.half], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineDoesNotExist_quarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineDoesNotExist_quarterEncodingsMatchTheCurrentlyActiveOnes_capturerWasNotCalledASecondTime( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .fair + ) + + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .fair + ) + } + + // MARK: ThermalState: .fair | neuralEngineExists: true + + func test_updateCaptureQuality_thermalStateFair_neuralEngineExists_fullHalfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full, .half, .quarter], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineExists_halfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .half, + .quarter + ], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineExists_fullAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .full, + .quarter + ], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineExists_fullEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineExists_halfEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.half], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineExists_quarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .fair + ) + } + + func test_updateCaptureQuality_thermalStateFair_neuralEngineExists_quarterEncodingsMatchTheCurrentlyActiveOnes_capturerWasNotCalledASecondTime( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .fair + ) + + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 0, + neuralEngineExists: true, + thermalState: .fair + ) + } + + // MARK: thermalState: .serious | neuralEngineExists: false + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineDoesNotExist_fullHalfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full, .half, .quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .serious + ) + } + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineDoesNotExist_halfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .half, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .serious + ) + } + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineDoesNotExist_fullAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .full, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .serious + ) + } + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineDoesNotExist_fullEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .serious + ) + } + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineDoesNotExist_halfEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.half], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .serious + ) + } + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineDoesNotExist_quarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .serious + ) + } + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineDoesNotExist_quarterEncodingsMatchTheCurrentlyActiveOnes_capturerWasNotCalledASecondTime( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .serious + ) + + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .serious + ) } - func test_updateCaptureQuality_halfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec() async throws { - try await assertUpdateCaptureQuality(expected: [.half, .quarter]) + // MARK: thermalState: .serious | neuralEngineExists: true + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineExists_fullHalfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full, .half, .quarter], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .serious + ) } - func test_updateCaptureQuality_fullAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec() async throws { - try await assertUpdateCaptureQuality(expected: [.full, .quarter]) + func test_updateCaptureQuality_thermalStateSerious_neuralEngineExists_halfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .half, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .serious + ) } - func test_updateCaptureQuality_fullEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec() async throws { - try await assertUpdateCaptureQuality(expected: [.full]) + func test_updateCaptureQuality_thermalStateSerious_neuralEngineExists_fullAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .full, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .serious + ) } - func test_updateCaptureQuality_halfEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec() async throws { - try await assertUpdateCaptureQuality(expected: [.half]) + func test_updateCaptureQuality_thermalStateSerious_neuralEngineExists_fullEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .serious + ) } - func test_updateCaptureQuality_quarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec() async throws { - try await assertUpdateCaptureQuality(expected: [.quarter]) + func test_updateCaptureQuality_thermalStateSerious_neuralEngineExists_halfEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.half], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .serious + ) + } + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineExists_quarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .serious + ) + } + + func test_updateCaptureQuality_thermalStateSerious_neuralEngineExists_quarterEncodingsMatchTheCurrentlyActiveOnes_capturerWasNotCalledASecondTime( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .serious + ) + + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .serious + ) + } + + // MARK: thermalState: .critical | neuralEngineExists: false + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineDoesNotExist_fullHalfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full, .half, .quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineDoesNotExist_halfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .half, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineDoesNotExist_fullAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .full, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineDoesNotExist_fullEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineDoesNotExist_halfEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.half], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineDoesNotExist_quarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineDoesNotExist_quarterEncodingsMatchTheCurrentlyActiveOnes_capturerWasNotCalledASecondTime( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .critical + ) + + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: false, + thermalState: .critical + ) + } + + // MARK: thermalState: .critical | neuralEngineExists: true + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineExists_fullHalfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full, .half, .quarter], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineExists_halfAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .half, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineExists_fullAndQuarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [ + .full, + .quarter + ], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineExists_fullEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.full], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineExists_halfEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.half], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineExists_quarterEncodingsAreActive_capturerWasCalledWithExpectedVideoCodec( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .critical + ) + } + + func test_updateCaptureQuality_thermalStateCritical_neuralEngineExists_quarterEncodingsMatchTheCurrentlyActiveOnes_capturerWasNotCalledASecondTime( + ) async throws { + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .critical + ) + + try await assertUpdateCaptureQuality( + expected: [.quarter], + expectedTimesCalled: 1, + neuralEngineExists: true, + thermalState: .critical + ) } // MARK: - Private helpers private func assertUpdateCaptureQuality( expected: [VideoCodec], + expectedTimesCalled: Int, + neuralEngineExists: Bool, + thermalState: ProcessInfo.ThermalState, file: StaticString = #file, line: UInt = #line ) async throws { + if subject == nil { + subject = .init { neuralEngineExists } + } + Self.thermalStateObserver.stub(for: \.state, with: thermalState) + try await subject.updateCaptureQuality( with: .init( expected.map(\.quality) @@ -78,16 +750,20 @@ final class AdaptiveVideoCapturePolicy_Tests: XCTestCase { XCTAssertEqual( cameraVideoCapturer.timesCalled(.updateCaptureQuality), - 1, - file: file, - line: line - ) - XCTAssertEqual( - cameraVideoCapturer.recordedInputPayload(([VideoCodec], AVCaptureDevice?).self, for: .updateCaptureQuality)?.first?.0 - .map(\.quality).sorted(), - expected.map(\.quality).sorted(), + expectedTimesCalled, file: file, line: line ) + + if expectedTimesCalled > 0 { + XCTAssertEqual( + cameraVideoCapturer.recordedInputPayload(([VideoCodec], AVCaptureDevice?).self, for: .updateCaptureQuality)?.first? + .0 + .map(\.quality).sorted(), + expected.map(\.quality).sorted(), + file: file, + line: line + ) + } } } diff --git a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift index 27ffec9a9..209ee3e2d 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCCoorindator_Tests.swift @@ -353,9 +353,13 @@ final class WebRTCCoordinator_Tests: XCTestCase, @unchecked Sendable { try await subject.changePinState(isEnabled: false, sessionId: user.id) - await assertNilAsync( - await subject.stateAdapter.participants[user.id]?.pin - ) + await fulfillment { + await self + .subject + .stateAdapter + .participants[self.user.id]? + .pin == nil + } } // MARK: - startNoiseCancellation diff --git a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift index e87b1ef22..dcf914460 100644 --- a/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift +++ b/StreamVideoTests/WebRTC/v2/WebRTCStateAdapter_Tests.swift @@ -469,9 +469,13 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { for: participant.sessionId ) - await assertNilAsync( - await subject.participants[participant.sessionId]?.track?.trackId - ) + await fulfillment { + await self + .subject + .participants[participant.sessionId]? + .track? + .trackId == nil + } } func test_didRemoveTrack_screenSharingOfExistingParticipant_shouldRemoveTrack() async throws { @@ -486,9 +490,13 @@ final class WebRTCStateAdapter_Tests: XCTestCase, @unchecked Sendable { for: participant.sessionId ) - await assertNilAsync( - await subject.participants[participant.sessionId]?.screenshareTrack?.trackId - ) + await fulfillment { + await self + .subject + .participants[participant.sessionId]? + .screenshareTrack? + .trackId == nil + } } // MARK: - trackFor