新增运营区域与飞手认证模块,并完善直播推流就绪流程
将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 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