Add Tasks module with home routing for task management flows.

Implement task list, detail, and create pages with API integration, cloud/local attachment support, and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 15:26:13 +08:00
parent b7054145e7
commit 3e898d93d0
23 changed files with 2795 additions and 8 deletions

View File

@ -0,0 +1,323 @@
//
// 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 { "" }
}