// // TaskViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/23. // import Foundation import XCTest @testable import suixinkan @MainActor /// 任务 ViewModel 测试,覆盖列表、发布任务和云盘选择逻辑。 final class TaskViewModelTests: XCTestCase { /// 测试无景区时清空任务列表且不请求接口。 func testTaskManagementClearsWithoutScenic() async throws { let api = TaskMockService() api.taskListResponses = [try TestFixture.payload(ListPayload.self, named: "task_list_success")] let viewModel = TaskManagementViewModel() try await viewModel.reload(api: api, scenicId: 88) XCTAssertFalse(viewModel.tasks.isEmpty) try await viewModel.reload(api: api, scenicId: nil) XCTAssertTrue(viewModel.tasks.isEmpty) XCTAssertEqual(viewModel.total, 0) XCTAssertEqual(viewModel.page, 1) XCTAssertEqual(api.taskListCalls.count, 1) XCTAssertEqual(viewModel.errorMessage, "请先选择景区") } /// 测试任务列表首屏和加载更多会更新分页。 func testTaskManagementReloadAndLoadMore() async throws { let api = TaskMockService() api.taskListResponses = [ try TestFixture.payload(ListPayload.self, named: "task_list_success"), try TestFixture.payload(ListPayload.self, named: "task_list_page2_success") ] let viewModel = TaskManagementViewModel() try await viewModel.reload(api: api, scenicId: 88) viewModel.total = 3 try await viewModel.loadMore(api: api, scenicId: 88) XCTAssertEqual(viewModel.tasks.count, 3) XCTAssertEqual(viewModel.page, 2) XCTAssertEqual(api.taskListCalls.map(\.page), [1, 2]) } /// 测试筛选参数会传给任务服务层。 func testTaskManagementUsesFilters() async throws { let api = TaskMockService() api.taskListResponses = [try TestFixture.payload(ListPayload.self, named: "task_list_success")] let viewModel = TaskManagementViewModel() viewModel.selectedStatus = .accepted viewModel.searchText = " 亲子 " viewModel.startDate = Self.date("2026-06-01") viewModel.endDate = Self.date("2026-06-30") try await viewModel.reload(api: api, scenicId: 88) let call = try XCTUnwrap(api.taskListCalls.first) XCTAssertEqual(call.taskStatus, TaskStatusFilter.accepted.rawValue) XCTAssertEqual(call.taskName, "亲子") XCTAssertEqual(call.startTime, "2026-06-01") XCTAssertEqual(call.endTime, "2026-06-30") } /// 测试任务详情加载成功后保存详情。 func testTaskDetailLoadsDetail() async throws { let api = TaskMockService() api.detailResponse = try TestFixture.payload(TaskDetailResponse.self, named: "task_detail_success") let viewModel = TaskDetailViewModel() await viewModel.load(api: api, taskId: 101) XCTAssertEqual(api.detailIds, [101]) XCTAssertEqual(viewModel.detail?.name, "游客精修任务") XCTAssertNil(viewModel.errorMessage) } /// 测试发布任务校验必填项和景区。 func testTaskCreateValidation() async throws { let api = TaskMockService() let upload = TaskMockUploadService() let viewModel = TaskCreateViewModel() let noScenic = try await viewModel.submit(api: api, uploadService: upload, scenicId: nil) XCTAssertFalse(noScenic) XCTAssertEqual(viewModel.errorMessage, "请先选择景区") viewModel.taskName = "任务" let noRemark = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) XCTAssertFalse(noRemark) XCTAssertEqual(viewModel.errorMessage, "请输入任务备注") } /// 测试发布任务会把云盘文件和本地上传文件写入请求。 func testTaskCreateUploadsLocalFilesBeforeSubmit() async throws { let api = TaskMockService() let upload = TaskMockUploadService() upload.taskUploadURLs = ["https://cdn.example.com/task/a.jpg"] let viewModel = TaskCreateViewModel() viewModel.taskName = "任务" viewModel.remark = "备注" viewModel.urgentHourText = "6" viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "")]) viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg") let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) XCTAssertTrue(success) XCTAssertEqual(upload.taskUploads.count, 1) let request = try XCTUnwrap(api.addTaskRequests.first) XCTAssertEqual(request.scenicId, "88") XCTAssertEqual(request.name, "任务") XCTAssertEqual(request.cloudFile.first?.fileId, 501) XCTAssertEqual(request.uploadFile.first?.fileUrl, "https://cdn.example.com/task/a.jpg") XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty) } /// 测试本地文件上传失败时不会提交发布任务。 func testTaskCreateUploadFailureStopsSubmit() async { let api = TaskMockService() let upload = TaskMockUploadService() upload.uploadError = APIError.networkFailed("上传失败") let viewModel = TaskCreateViewModel() viewModel.taskName = "任务" viewModel.remark = "备注" viewModel.addLocalFile(data: Data([1]), fileName: "local.jpg") do { _ = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) XCTFail("Expected upload failure") } catch APIError.networkFailed(let message) { XCTAssertEqual(message, "上传失败") } catch { XCTFail("Unexpected error: \(error)") } XCTAssertTrue(api.addTaskRequests.isEmpty) XCTAssertEqual(viewModel.selectedLocalFiles.first?.errorMessage, "网络请求失败:上传失败") } /// 测试云盘选择器进入文件夹、分页、选择文件和忽略文件夹选择。 func testCloudSelectionNavigationAndSelection() async throws { let api = TaskMockService() let firstPage = try TestFixture.payload(ListPayload.self, named: "cloud_file_list_success") let secondPage = ListPayload(total: 3, list: [firstPage.list[1]]) api.cloudFileResponses = [firstPage, secondPage, firstPage] let viewModel = TaskCloudFileSelectionViewModel() try await viewModel.reload(api: api) viewModel.total = 3 try await viewModel.loadMore(api: api) XCTAssertEqual(viewModel.files.count, 3) let folder = firstPage.list[0] try await viewModel.enterFolder(folder, api: api) XCTAssertEqual(viewModel.currentFolderId, folder.id) viewModel.toggleSelection(folder) XCTAssertTrue(viewModel.selectedFiles.isEmpty) let file = firstPage.list[1] viewModel.toggleSelection(file) XCTAssertEqual(viewModel.makeSelectionItems().first?.id, file.id) XCTAssertEqual(api.cloudFileCalls.map(\.page), [1, 2, 1]) } /// 测试发布任务会加载可关联订单,无景区时清空列表。 func testTaskCreateLoadAvailableOrders() async throws { let api = TaskMockService() api.availableOrders = try TestFixture.payload([AvailableOrderResponse].self, named: "available_order_success") let viewModel = TaskCreateViewModel() await viewModel.loadAvailableOrders(api: api, scenicId: nil) XCTAssertTrue(viewModel.availableOrders.isEmpty) XCTAssertTrue(api.availableOrderScenicIds.isEmpty) await viewModel.loadAvailableOrders(api: api, scenicId: 88) XCTAssertEqual(api.availableOrderScenicIds, [88]) XCTAssertEqual(viewModel.availableOrders.count, 1) XCTAssertFalse(viewModel.isLoadingOrders) } /// 测试加载可关联订单失败时会清空列表并提示错误。 func testTaskCreateLoadAvailableOrdersFailure() async { let api = TaskMockService() api.availableOrderError = APIError.networkFailed("订单加载失败") let viewModel = TaskCreateViewModel() await viewModel.loadAvailableOrders(api: api, scenicId: 88) XCTAssertTrue(viewModel.availableOrders.isEmpty) XCTAssertEqual(viewModel.errorMessage, "网络请求失败:订单加载失败") } /// 测试上传中的本地附件会阻止提交。 func testTaskCreateCanSubmitBlocksWhileUploading() { let viewModel = TaskCreateViewModel() viewModel.taskName = "任务" viewModel.remark = "备注" viewModel.selectedLocalFiles = [ TaskLocalUploadItem( id: UUID(), data: Data([1]), fileName: "local.jpg", fileType: 2, remark: "", uploadedURL: nil, progress: 50, errorMessage: nil ) ] XCTAssertFalse(viewModel.canSubmit) XCTAssertTrue(viewModel.selectedLocalFiles.first?.isUploading == true) XCTAssertEqual(viewModel.selectedLocalFiles.first?.statusText, "上传中 50%") } /// 测试云盘附件合并会去重,移除接口能删除指定文件。 func testTaskCreateMergeAndRemoveAttachments() { let viewModel = TaskCreateViewModel() let file = TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "备注") viewModel.mergeCloudFiles([file, file]) viewModel.addLocalFile(data: Data([1]), fileName: "photo.jpg") guard let localID = viewModel.selectedLocalFiles.first?.id else { return XCTFail("应存在本地附件") } XCTAssertEqual(viewModel.selectedCloudFiles.count, 1) viewModel.removeCloudFile(id: 501) viewModel.removeLocalFile(id: localID) XCTAssertTrue(viewModel.selectedCloudFiles.isEmpty) XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty) } /// 测试关联订单和紧急小时会写入发布任务请求。 func testTaskCreateSubmitIncludesSelectedOrderAndUrgentHour() async throws { let api = TaskMockService() let upload = TaskMockUploadService() let viewModel = TaskCreateViewModel() viewModel.taskName = " 任务 " viewModel.remark = "备注" viewModel.urgentHourText = " 12 " viewModel.selectOrder(taskAvailableOrder(orderNumber: "ORD-1001")) let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) XCTAssertTrue(success) let request = try XCTUnwrap(api.addTaskRequests.first) XCTAssertEqual(request.name, "任务") XCTAssertEqual(request.orderNumber, "ORD-1001") XCTAssertEqual(request.urgentHour, 12) } /// 测试任务名称和紧急小时校验失败时不会提交。 func testTaskCreateValidationCoversTaskNameAndUrgentHour() async throws { let api = TaskMockService() let upload = TaskMockUploadService() let viewModel = TaskCreateViewModel() viewModel.remark = "备注" let missingName = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) XCTAssertFalse(missingName) XCTAssertEqual(viewModel.errorMessage, "请输入任务名称") viewModel.taskName = "任务" viewModel.urgentHourText = "abc" let invalidHour = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88) XCTAssertFalse(invalidHour) XCTAssertEqual(viewModel.errorMessage, "请输入正确的紧急小时") } /// 测试本地视频附件会识别为视频类型。 func testTaskCreateDetectsVideoFileType() { let viewModel = TaskCreateViewModel() viewModel.addLocalFile(data: Data([1]), fileName: "clip.mp4") XCTAssertEqual(viewModel.selectedLocalFiles.first?.fileType, 1) } /// 测试云盘选择器支持返回上级目录、搜索筛选和取消选择。 func testCloudSelectionPopToFolderSearchAndDeselect() async throws { let api = TaskMockService() let firstPage = try TestFixture.payload(ListPayload.self, named: "cloud_file_list_success") api.cloudFileResponses = [firstPage, firstPage] let viewModel = TaskCloudFileSelectionViewModel() let folder = firstPage.list[0] let file = firstPage.list[1] try await viewModel.enterFolder(folder, api: api) viewModel.searchText = " photo " viewModel.selectedFilter = .image try await viewModel.reload(api: api) let call = try XCTUnwrap(api.cloudFileCalls.last) XCTAssertEqual(call.parentFolderId, folder.id) XCTAssertEqual(call.name, "photo") XCTAssertEqual(call.type, TaskCloudFileFilter.image.rawValue) viewModel.toggleSelection(file) XCTAssertTrue(viewModel.isSelected(file)) viewModel.toggleSelection(file) XCTAssertFalse(viewModel.isSelected(file)) try await viewModel.popToFolder(at: 0, api: api) XCTAssertEqual(viewModel.currentFolderId, 0) } /// 测试云盘列表加载失败时会清空状态并抛出错误。 func testCloudSelectionReloadFailureClearsState() async { let api = TaskMockService() api.cloudFileError = APIError.networkFailed("云盘失败") let viewModel = TaskCloudFileSelectionViewModel() viewModel.files = [CloudDriveFile(id: 1, name: "占位")] viewModel.total = 5 viewModel.page = 2 do { try await viewModel.reload(api: api) XCTFail("加载失败时应抛出错误") } catch APIError.networkFailed(let message) { XCTAssertEqual(message, "云盘失败") } catch { XCTFail("意外错误: \(error)") } XCTAssertTrue(viewModel.files.isEmpty) XCTAssertEqual(viewModel.total, 0) XCTAssertEqual(viewModel.page, 1) XCTAssertEqual(viewModel.errorMessage, "网络请求失败:云盘失败") } /// 测试云盘没有更多数据时不会继续分页。 func testCloudSelectionLoadMoreStopsWhenNoMore() async throws { let api = TaskMockService() let page = try TestFixture.payload(ListPayload.self, named: "cloud_file_list_success") api.cloudFileResponses = [page] let viewModel = TaskCloudFileSelectionViewModel() try await viewModel.reload(api: api) viewModel.total = viewModel.files.count try await viewModel.loadMore(api: api) XCTAssertEqual(api.cloudFileCalls.count, 1) XCTAssertFalse(viewModel.isLoadingMore) } /// 构造固定日期。 private static func date(_ text: String) -> Date { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" return formatter.date(from: text)! } } @MainActor /// 任务服务测试替身。 private final class TaskMockService: TaskServing { struct TaskListCall { let scenicId: Int let page: Int let pageSize: Int let taskStatus: Int let taskName: String? let startTime: String? let endTime: String? } struct CloudFileCall { let parentFolderId: Int let name: String let type: Int let orderBy: Int let page: Int let pageSize: Int } var taskListResponses: [ListPayload] = [] var detailResponse: TaskDetailResponse? var availableOrders: [AvailableOrderResponse] = [] var availableOrderError: Error? var cloudFileResponses: [ListPayload] = [] var cloudFileError: Error? private(set) var taskListCalls: [TaskListCall] = [] private(set) var detailIds: [Int] = [] private(set) var availableOrderScenicIds: [Int] = [] private(set) var addTaskRequests: [AddTaskRequest] = [] private(set) var cloudFileCalls: [CloudFileCall] = [] /// 记录任务列表请求并返回下一份响应。 func taskList( scenicId: Int, page: Int, pageSize: Int, taskStatus: Int, taskName: String?, startTime: String?, endTime: String? ) async throws -> ListPayload { taskListCalls.append( TaskListCall( scenicId: scenicId, page: page, pageSize: pageSize, taskStatus: taskStatus, taskName: taskName, startTime: startTime, endTime: endTime ) ) return taskListResponses.isEmpty ? ListPayload(total: 0, list: []) : taskListResponses.removeFirst() } /// 记录任务详情请求并返回响应。 func taskDetail(id: Int) async throws -> TaskDetailResponse { detailIds.append(id) return detailResponse! } /// 记录可关联订单请求并返回响应。 func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] { availableOrderScenicIds.append(scenicId) if let availableOrderError { throw availableOrderError } return availableOrders } /// 记录发布任务请求。 func addTask(_ request: AddTaskRequest) async throws { addTaskRequests.append(request) } /// 记录云盘文件请求并返回下一份响应。 func cloudFileList( parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int ) async throws -> ListPayload { cloudFileCalls.append( CloudFileCall( parentFolderId: parentFolderId, name: name, type: type, orderBy: orderBy, page: page, pageSize: pageSize ) ) if let cloudFileError { throw cloudFileError } return cloudFileResponses.isEmpty ? ListPayload(total: 0, list: []) : cloudFileResponses.removeFirst() } } /// 构造可关联订单测试数据。 private func taskAvailableOrder(orderNumber: String) -> AvailableOrderResponse { let data = Data(#"{"project_name":"项目","order_number":"\#(orderNumber)","order_status":"1","order_status_label":"已支付","pay_time":"2026-06-24","user_phone":"13800000000"}"#.utf8) return try! JSONDecoder().decode(AvailableOrderResponse.self, from: data) } @MainActor /// OSS 上传服务测试替身。 private final class TaskMockUploadService: OSSUploadServing { struct TaskUpload { let data: Data let fileName: String let fileType: Int let scenicId: Int } var taskUploadURLs: [String] = [] var uploadError: Error? private(set) var taskUploads: [TaskUpload] = [] /// 模拟上传任务附件。 func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { taskUploads.append(TaskUpload(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId)) onProgress(50) if let uploadError { throw uploadError } onProgress(100) return taskUploadURLs.isEmpty ? "https://cdn.example.com/task/default.jpg" : taskUploadURLs.removeFirst() } /// 模拟上传项目图片。 func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } /// 模拟上传头像。 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 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 { "" } }