// // 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]) } /// 构造固定日期。 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 cloudFileResponses: [ListPayload] = [] 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) 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 ) ) return cloudFileResponses.isEmpty ? ListPayload(total: 0, list: []) : cloudFileResponses.removeFirst() } } @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 { "" } }