Files
suixinkan_ios_new/suixinkanTests/Live/LiveViewModelTests.swift
汉秋 41dda3cc9b 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>
2026-06-25 18:15:59 +08:00

505 lines
21 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// LiveViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class LiveViewModelTests: XCTestCase {
func testLiveManagementReloadLoadMoreAndMissingScenicReset() async {
let api = LiveMock()
api.liveListResponses = [
LiveListResponse(items: [live(id: 1, status: 2), live(id: 2, status: 3)], total: 3, page: 1, pageSize: 2),
LiveListResponse(items: [live(id: 3, status: 1)], total: 3, page: 2, pageSize: 2)
]
let viewModel = LiveManagementViewModel()
await viewModel.reload(api: api, scenicId: 88)
await viewModel.loadMore(api: api, scenicId: 88)
await viewModel.loadMore(api: api, scenicId: 88)
XCTAssertEqual(api.liveListRequests.map(\.page), [1, 2])
XCTAssertEqual(viewModel.items.map(\.id), [1, 2, 3])
XCTAssertEqual(viewModel.liveRunningCount, 1)
XCTAssertEqual(viewModel.liveFinishedCount, 1)
XCTAssertFalse(viewModel.hasMore)
await viewModel.reload(api: api, scenicId: nil)
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertNil(viewModel.detail)
XCTAssertFalse(viewModel.hasMore)
}
func testLiveManagementReloadFailureAndDetailFailureClearStaleState() async {
let api = LiveMock()
api.liveListResponses = [LiveListResponse(items: [live(id: 1)], total: 1)]
api.liveDetailResponses = [live(id: 1, title: "旧详情")]
let viewModel = LiveManagementViewModel()
await viewModel.reload(api: api, scenicId: 88)
await viewModel.loadDetail(api: api, liveId: 1)
XCTAssertEqual(viewModel.items.map(\.id), [1])
XCTAssertEqual(viewModel.detail?.title, "旧详情")
api.liveListError = LiveTestError.sample
await viewModel.reload(api: api, scenicId: 88)
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertNil(viewModel.detail)
XCTAssertFalse(viewModel.hasMore)
XCTAssertEqual(viewModel.errorMessage, "测试错误")
api.liveListError = nil
api.liveDetailError = LiveTestError.sample
await viewModel.loadDetail(api: api, liveId: 1)
XCTAssertNil(viewModel.detail)
}
func testLiveManagementActionsRefreshAfterSuccess() async throws {
let api = LiveMock()
api.liveListResponses = [
LiveListResponse(items: [live(id: 1, status: 1)], total: 1),
LiveListResponse(items: [live(id: 1, status: 2)], total: 1),
LiveListResponse(items: [live(id: 1, status: 3)], total: 1),
LiveListResponse(items: [live(id: 4, status: 1)], total: 1)
]
let viewModel = LiveManagementViewModel()
await viewModel.reload(api: api, scenicId: 88)
try await viewModel.control(api: api, item: live(id: 1, status: 1), scenicId: 88)
XCTAssertEqual(api.startIds, [1])
XCTAssertEqual(viewModel.items.first?.status, 2)
try await viewModel.finish(api: api, item: live(id: 1, status: 2), scenicId: 88)
XCTAssertEqual(api.finishIds, [1])
XCTAssertEqual(viewModel.items.first?.status, 3)
try await viewModel.create(api: api, scenicId: 88, title: " 新直播 ", coverURL: " https://cdn.example.com/c.jpg ")
XCTAssertEqual(api.createRequests.first?.title, "新直播")
XCTAssertEqual(api.createRequests.first?.coverImg, "https://cdn.example.com/c.jpg")
XCTAssertEqual(viewModel.items.first?.id, 4)
}
func testLiveDetailActionsRefreshDetail() async throws {
let api = LiveMock()
api.liveDetailResponses = [
live(id: 1, status: 2, manualPushMode: 1),
live(id: 1, status: 3, manualPushMode: 1),
live(id: 1, status: 3, manualPushMode: 2)
]
let viewModel = LiveDetailViewModel(detail: live(id: 1, status: 1, manualPushMode: 1))
try await viewModel.control(api: api)
XCTAssertEqual(api.startIds, [1])
XCTAssertEqual(viewModel.detail.status, 2)
try await viewModel.finish(api: api)
XCTAssertEqual(api.finishIds, [1])
XCTAssertEqual(viewModel.detail.status, 3)
try await viewModel.setPushMode(api: api, mode: 2)
XCTAssertEqual(api.pushModeRequests.map(\.mode), [2])
XCTAssertEqual(viewModel.detail.manualPushMode, 2)
}
func testLiveAlbumDateValidationReloadLoadMoreAndFailureBehavior() async {
let api = LiveMock()
api.albumListResponses = [
LiveAlbumFolderListResponse(items: [folder(id: 1)], page: 1, pageSize: 1, total: 2),
LiveAlbumFolderListResponse(items: [folder(id: 2)], page: 2, pageSize: 1, total: 2)
]
let viewModel = LiveAlbumViewModel()
XCTAssertNoThrow(try viewModel.setStartDate(date("2026-05-01")))
XCTAssertNoThrow(try viewModel.setEndDate(date("2026-05-23")))
await viewModel.reload(api: api, scenicId: 88)
await viewModel.loadMore(api: api, scenicId: 88)
XCTAssertEqual(api.albumListRequests.map(\.page), [1, 2])
XCTAssertEqual(api.albumListRequests.first?.startTime, "2026-05-01")
XCTAssertEqual(api.albumListRequests.first?.endTime, "2026-05-23")
XCTAssertEqual(viewModel.folders.map(\.id), [1, 2])
XCTAssertFalse(viewModel.hasMore)
XCTAssertThrowsError(try viewModel.setStartDate(date("2026-06-01")))
api.albumListError = LiveTestError.sample
await viewModel.reload(api: api, scenicId: 88)
XCTAssertTrue(viewModel.folders.isEmpty)
XCTAssertFalse(viewModel.hasMore)
await viewModel.reload(api: api, scenicId: nil)
XCTAssertTrue(viewModel.folders.isEmpty)
}
func testLiveAlbumCreateUploadsFilesAndClearsForm() async throws {
let api = LiveMock()
let uploader = LiveUploadMock()
let uploadedURLs = ["https://cdn.example.com/live_albums/a.jpg", "https://cdn.example.com/live_albums/b.mp4"]
uploader.urls = uploadedURLs
let viewModel = LiveAlbumCreateViewModel()
viewModel.name = " 五一相册 "
viewModel.addLocalFiles([
LiveAlbumLocalUploadFile(data: Data([1, 2, 3]), fileName: "a.jpg", fileType: 1),
LiveAlbumLocalUploadFile(data: Data([4, 5, 6]), fileName: "b.mp4", fileType: 2)
])
try await viewModel.submit(scenicId: 88, api: api, uploader: uploader)
XCTAssertEqual(uploader.uploads.map(\.fileName), ["a.jpg", "b.mp4"])
let request = try XCTUnwrap(api.albumCreateRequests.first)
XCTAssertEqual(request.scenicId, "88")
XCTAssertEqual(request.name, "五一相册")
XCTAssertEqual(request.items.map(\.url), uploadedURLs)
XCTAssertEqual(request.items.map(\.type), [1, 2])
XCTAssertTrue(viewModel.name.isEmpty)
XCTAssertTrue(viewModel.localFiles.isEmpty)
XCTAssertEqual(viewModel.uploadProgress, 100)
}
func testLiveAlbumCreateFailureKeepsForm() async {
let api = LiveMock()
let uploader = LiveUploadMock()
uploader.error = LiveTestError.sample
let viewModel = LiveAlbumCreateViewModel()
viewModel.name = "相册"
viewModel.addLocalFiles([LiveAlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 1)])
await XCTAssertThrowsErrorAsync(try await viewModel.submit(scenicId: 88, api: api, uploader: uploader))
XCTAssertEqual(viewModel.name, "相册")
XCTAssertEqual(viewModel.localFiles.count, 1)
XCTAssertTrue(api.albumCreateRequests.isEmpty)
}
func testLiveAlbumPreviewLoadAndDeleteRefresh() async throws {
let api = LiveMock()
api.albumDetailResponses = [
folder(id: 1, items: [albumFile(id: 11), albumFile(id: 12)]),
folder(id: 1, items: [albumFile(id: 12)])
]
let viewModel = LiveAlbumPreviewViewModel(folderId: 1, startIndex: 0, summary: nil)
await viewModel.load(api: api)
XCTAssertEqual(viewModel.files.map(\.id), [11, 12])
try await viewModel.deleteCurrentFile(api: api)
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
private final class LiveMock: LiveServing {
var liveListResponses: [LiveListResponse] = []
var liveDetailResponses: [LiveEntity] = []
var albumListResponses: [LiveAlbumFolderListResponse] = []
var albumDetailResponses: [LiveAlbumFolderItem] = []
var liveListError: Error?
var liveDetailError: Error?
var albumListError: Error?
var albumDetailError: Error?
var createRequests: [LiveCreateRequest] = []
var startIds: [Int] = []
var stopIds: [Int] = []
var finishIds: [Int] = []
var pushModeRequests: [(liveId: Int, mode: Int)] = []
var albumCreateRequests: [LiveAlbumCreateFolderRequest] = []
var albumDeleteFolderIds: [Int] = []
var albumDeleteFileRequests: [(folderId: Int, fileIds: [Int])] = []
private(set) var liveListRequests: [(scenicId: Int, page: Int, pageSize: Int)] = []
private(set) var albumListRequests: [(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int)] = []
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse {
liveListRequests.append((scenicId, page, pageSize))
if let liveListError { throw liveListError }
return liveListResponses.isEmpty ? LiveListResponse() : liveListResponses.removeFirst()
}
func liveDetail(liveId: Int) async throws -> LiveEntity {
if let liveDetailError { throw liveDetailError }
return liveDetailResponses.isEmpty ? live(id: liveId) : liveDetailResponses.removeFirst()
}
func liveCreate(_ request: LiveCreateRequest) async throws {
createRequests.append(request)
}
func liveStart(liveId: Int) async throws {
startIds.append(liveId)
}
func liveStop(liveId: Int) async throws {
stopIds.append(liveId)
}
func liveFinish(liveId: Int) async throws {
finishIds.append(liveId)
}
func liveSetPushMode(liveId: Int, mode: Int) async throws {
pushModeRequests.append((liveId, mode))
}
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse {
albumListRequests.append((scenicId, startTime, endTime, page, pageSize))
if let albumListError { throw albumListError }
return albumListResponses.isEmpty ? LiveAlbumFolderListResponse() : albumListResponses.removeFirst()
}
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws {
albumCreateRequests.append(request)
}
func liveAlbumDeleteFolder(folderId: Int) async throws {
albumDeleteFolderIds.append(folderId)
}
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem {
if let albumDetailError { throw albumDetailError }
return albumDetailResponses.isEmpty ? folder(id: folderId) : albumDetailResponses.removeFirst()
}
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws {
albumDeleteFileRequests.append((folderId, fileIds))
}
}
@MainActor
private final class LiveUploadMock: OSSUploadServing {
var urls: [String] = []
var error: Error?
private(set) var uploads: [(data: Data, fileName: String, fileType: Int, scenicId: Int)] = []
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if let error { throw error }
uploads.append((data, fileName, fileType, scenicId))
onProgress(100)
return urls.isEmpty ? "https://cdn.example.com/\(fileName)" : urls.removeFirst()
}
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
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,
title: title,
coverImg: "https://cdn.example.com/\(id).jpg",
pushUrl: "rtmp://push.example.com/live/\(id)",
duration: 3600,
status: status,
statusLabel: status == 2 ? "直播中" : (status == 3 ? "已结束" : "待开始"),
manualPushMode: manualPushMode,
viewsCount: 10
)
}
private func folder(id: Int, items: [LiveAlbumFileItem] = [albumFile(id: 1)]) -> LiveAlbumFolderItem {
LiveAlbumFolderItem(id: id, albumId: id + 100, name: "相册\(id)", creator: "摄影师", items: items)
}
private func albumFile(id: Int) -> LiveAlbumFileItem {
LiveAlbumFileItem(id: id, url: "https://cdn.example.com/\(id).jpg", type: 1, size: 100)
}
private func date(_ value: String) -> Date {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.date(from: value)!
}
private enum LiveTestError: LocalizedError {
case sample
var errorDescription: String? { "测试错误" }
}
private func XCTAssertThrowsErrorAsync<T>(
_ expression: @autoclosure () async throws -> T,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
_ = try await expression()
XCTFail("Expected error", file: file, line: line)
} catch {}
}