// // 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]) } } @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 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( _ expression: @autoclosure () async throws -> T, file: StaticString = #filePath, line: UInt = #line ) async { do { _ = try await expression() XCTFail("Expected error", file: file, line: line) } catch {} }