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:
2026-06-25 18:15:59 +08:00
parent fdf4659048
commit 41dda3cc9b
33 changed files with 3455 additions and 37 deletions

View File

@ -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,