Add OperatingArea and PilotCertification modules with live push readiness.
Migrate operating area and pilot certification from home placeholders, extend Live with playback and push readiness flows, and add pilot certificate OSS upload. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -192,6 +192,107 @@ final class LiveViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(api.albumDeleteFileRequests.first?.fileIds, [11])
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [12])
|
||||
}
|
||||
|
||||
func testLiveEntityDecodesPlaybackURLsAndResolverExcludesRTMP() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": "1",
|
||||
"title": "播放测试",
|
||||
"cover_img": "https://cdn.example.com/c.jpg",
|
||||
"push_url": "rtmp://push.example.com/live/1",
|
||||
"play_url": "rtmp://pull.example.com/live/1",
|
||||
"pull_url": "https://cdn.example.com/live/1.flv",
|
||||
"hls_url": "https://cdn.example.com/live/1.m3u8",
|
||||
"live_url": "https://cdn.example.com/live/1.mp4"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let live = try JSONDecoder().decode(LiveEntity.self, from: json)
|
||||
|
||||
XCTAssertEqual(live.playUrl, "rtmp://pull.example.com/live/1")
|
||||
XCTAssertEqual(live.pullUrl, "https://cdn.example.com/live/1.flv")
|
||||
XCTAssertEqual(live.hlsUrl, "https://cdn.example.com/live/1.m3u8")
|
||||
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pushUrl))
|
||||
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.playUrl))
|
||||
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pullUrl))
|
||||
XCTAssertEqual(LivePlaybackURLResolver.playableURL(from: live)?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
}
|
||||
|
||||
func testLivePlaybackViewModelStateTransitions() {
|
||||
let viewModel = LivePlaybackViewModel(urlString: "https://cdn.example.com/live/1.m3u8")
|
||||
|
||||
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
XCTAssertFalse(viewModel.isPlaying)
|
||||
|
||||
viewModel.play()
|
||||
XCTAssertTrue(viewModel.isPlaying)
|
||||
XCTAssertNotNil(viewModel.player)
|
||||
|
||||
viewModel.pause()
|
||||
XCTAssertFalse(viewModel.isPlaying)
|
||||
|
||||
viewModel.release()
|
||||
XCTAssertNil(viewModel.player)
|
||||
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
|
||||
viewModel.load(urlString: "rtmp://push.example.com/live/1")
|
||||
XCTAssertNil(viewModel.playableURL)
|
||||
XCTAssertEqual(viewModel.errorMessage, "暂无可播放地址")
|
||||
}
|
||||
|
||||
func testLivePushReadinessPermissionsNetworkAndUnsupportedAdapter() async {
|
||||
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .wifi)
|
||||
let adapter = LivePushAdapterMock(available: false)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
XCTAssertEqual(viewModel.cameraPermission, .granted)
|
||||
XCTAssertEqual(viewModel.microphonePermission, .granted)
|
||||
XCTAssertEqual(viewModel.networkState, .wifi)
|
||||
XCTAssertEqual(viewModel.sdkStatusText, "未接入真推流 SDK")
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.prepare())
|
||||
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription)
|
||||
}
|
||||
|
||||
func testLivePushReadinessDiagnosticsDoNotPrepareUnsupportedAdapter() async {
|
||||
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .wifi)
|
||||
let adapter = LivePushAdapterMock(available: false)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
XCTAssertThrowsError(try viewModel.runDiagnostics())
|
||||
XCTAssertFalse(adapter.prepareCalled)
|
||||
XCTAssertFalse(adapter.startCalled)
|
||||
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription)
|
||||
}
|
||||
|
||||
func testLivePushReadinessBlocksDeniedPermissionAndUnavailableNetwork() async {
|
||||
let permissions = LivePermissionMock(camera: .denied, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .unavailable)
|
||||
let adapter = LivePushAdapterMock(available: true)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.prepare())
|
||||
XCTAssertFalse(adapter.prepareCalled)
|
||||
|
||||
let missingURLViewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
missingURLViewModel.configure(pushURL: "")
|
||||
await XCTAssertThrowsErrorAsync(try await missingURLViewModel.prepare())
|
||||
XCTAssertEqual(missingURLViewModel.errorMessage, LivePushReadinessError.missingPushURL.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -294,6 +395,69 @@ private final class LiveUploadMock: OSSUploadServing {
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
|
||||
private struct LivePermissionMock: LivePermissionProviding {
|
||||
let camera: LivePushPermissionState
|
||||
let microphone: LivePushPermissionState
|
||||
|
||||
func cameraPermission() async -> LivePushPermissionState {
|
||||
camera
|
||||
}
|
||||
|
||||
func microphonePermission() async -> LivePushPermissionState {
|
||||
microphone
|
||||
}
|
||||
}
|
||||
|
||||
private final class LiveNetworkMock: LiveNetworkMonitoring {
|
||||
private(set) var currentState: LivePushNetworkState
|
||||
private var onChange: (@Sendable (LivePushNetworkState) -> Void)?
|
||||
|
||||
init(state: LivePushNetworkState) {
|
||||
currentState = state
|
||||
}
|
||||
|
||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
|
||||
self.onChange = onChange
|
||||
onChange(currentState)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
onChange = nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class LivePushAdapterMock: LivePushAdapter {
|
||||
let name = "Mock SDK"
|
||||
let isAvailable: Bool
|
||||
var prepareCalled = false
|
||||
var startCalled = false
|
||||
var stopCalled = false
|
||||
|
||||
init(available: Bool) {
|
||||
isAvailable = available
|
||||
}
|
||||
|
||||
func prepare(pushURL: URL) async throws {
|
||||
prepareCalled = true
|
||||
if !isAvailable {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
startCalled = true
|
||||
if !isAvailable {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async throws {
|
||||
stopCalled = true
|
||||
}
|
||||
|
||||
func dispose() async {}
|
||||
}
|
||||
|
||||
private func live(id: Int, title: String = "直播", status: Int = 1, manualPushMode: Int = 1) -> LiveEntity {
|
||||
LiveEntity(
|
||||
id: id,
|
||||
|
||||
Reference in New Issue
Block a user