Implement task list, detail, and create pages with API integration, cloud/local attachment support, and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
324 lines
12 KiB
Swift
324 lines
12 KiB
Swift
//
|
||
// 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<PhotographerTaskItem>.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<PhotographerTaskItem>.self, named: "task_list_success"),
|
||
try TestFixture.payload(ListPayload<PhotographerTaskItem>.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<PhotographerTaskItem>.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<CloudDriveFile>.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<PhotographerTaskItem>] = []
|
||
var detailResponse: TaskDetailResponse?
|
||
var availableOrders: [AvailableOrderResponse] = []
|
||
var cloudFileResponses: [ListPayload<CloudDriveFile>] = []
|
||
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<PhotographerTaskItem> {
|
||
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<CloudDriveFile> {
|
||
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 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 { "" }
|
||
}
|